github.com/anthropics/anthropic-sdk-go@v1.70.1/lib/environments/poller.go (about) 1 // Package environments provides helpers for running self-hosted environment 2 // workers: the control-plane [WorkPoller] (claim work items and hand them 3 // back) and the [EnvironmentWorker] composition (poll + skills + run the 4 // session tools while heartbeating + force-stop unless the lease was lost + 5 // loop). They are designed to feel at home alongside the SDK's other 6 // iterators (ssestream.Stream[T], BetaToolRunner): pull-style 7 // Next/Current/Err/Close plus a Go 1.23 range-over-func All(). 8 // 9 // The per-session tool-execution loop itself lives next to the Messages tool 10 // runner as [github.com/anthropics/anthropic-sdk-go.SessionToolRunner] 11 // (client.Beta.Sessions.Events.NewToolRunner), and the agent_toolset_20260401 12 // tool implementations live in 13 // [github.com/anthropics/anthropic-sdk-go/tools/agenttoolset]. 14 package environments 15 16 import ( 17 "context" 18 crand "crypto/rand" 19 "encoding/hex" 20 "errors" 21 "fmt" 22 "io" 23 "iter" 24 "log/slog" 25 "math/rand/v2" 26 "net/http" 27 "os" 28 "time" 29 30 "github.com/anthropics/anthropic-sdk-go" 31 "github.com/anthropics/anthropic-sdk-go/internal/stainlessheader" 32 "github.com/anthropics/anthropic-sdk-go/option" 33 "github.com/anthropics/anthropic-sdk-go/packages/param" 34 ) 35 36 // bearerReqOpts returns the auth-only per-request options every 37 // runner-helper API call must carry: 38 // 39 // - WithHeaderDel("X-Api-Key") clears the parent client's default API key 40 // so it doesn't ride on the wire alongside the bearer credential. 41 // option.WithAuthToken alone only *adds* an Authorization header — the 42 // parent's WithAPIKey middleware still sets X-Api-Key, so both end up on 43 // every request and the server's route-by-route precedence is what would 44 // otherwise decide which one auth gets. 45 // - WithAuthToken sets the environment key as the Authorization bearer. 46 // 47 // Use this for pass-through call sites where the downstream helper stamps 48 // its own x-stainless-helper header (the SessionToolRunner's stream/list/send 49 // path). For call sites that own their helper tag (the WorkPoller and the 50 // EnvironmentWorker's heartbeat/force-stop), use [helperReqOpts] instead. 51 // 52 // Returns an error on an empty environmentKey: option.WithAuthToken("") 53 // produces a malformed "Authorization: Bearer " header that 401s with a 54 // non-obvious server-side error. All callers already validate 55 // EnvironmentKey at the worker/poller option boundary; this guard is a 56 // belt-and-braces for a future caller that reaches the helpers through a 57 // path that misses that validation. 58 func bearerReqOpts(environmentKey string) ([]option.RequestOption, error) { 59 if environmentKey == "" { 60 return nil, errors.New("environments: environment key is required") 61 } 62 return []option.RequestOption{ 63 option.WithHeaderDel("X-Api-Key"), 64 option.WithAuthToken(environmentKey), 65 }, nil 66 } 67 68 // helperReqOpts wraps [bearerReqOpts] with an x-stainless-helper telemetry 69 // tag so the control plane can attribute requests to the specific helper 70 // that issued them. Use this for the WorkPoller and the EnvironmentWorker's 71 // heartbeat/force-stop; the SessionToolRunner stamps its own tag internally 72 // and so should pass [bearerReqOpts] through unchanged. 73 // 74 // Surfaces [bearerReqOpts]'s empty-key error to the caller. 75 func helperReqOpts(environmentKey string, helper stainlessheader.Value) ([]option.RequestOption, error) { 76 opts, err := bearerReqOpts(environmentKey) 77 if err != nil { 78 return nil, err 79 } 80 return append(opts, stainlessheader.With(helper)), nil 81 } 82 83 // ManagedAgentsBeta is the anthropic-beta value gating self-hosted 84 // environments access. The work resource and the sessions.events.* 85 // resource both auto-inject this header on every call, so the runner 86 // does not need to set it explicitly; the constant is exported so 87 // consumers issuing custom requests against the same APIs can reference 88 // it. 89 // 90 // As of the managed-agents-2026-04-01 rollup, this beta replaces the 91 // older `environments-2026-03-01` header and shares the value with the 92 // managed-agents beta used elsewhere in the SDK. 93 // 94 // TODO(codegen): the generator emits "managed-agents-2026-04-01" as a 95 // string literal in every relevant method body rather than as an 96 // exported named constant. Source from a generated constant when 97 // one exists. 98 const ManagedAgentsBeta = "managed-agents-2026-04-01" 99 100 const ( 101 // pollBlockMillis is the long-poll block_ms we request from the API. 102 // The server caps this at 999 — we rely on client-side jitter between 103 // empty polls to spread reconnections across workers. 104 pollBlockMillis = 999 105 106 pollBackoffCap = 60 * time.Second 107 108 // stopTimeout bounds the deferred Stop call so a slow server cannot 109 // stall the consumer's next iteration or Close indefinitely. 110 stopTimeout = 10 * time.Second 111 112 idleReportInterval = 5 * time.Minute 113 ) 114 115 // idleLog keeps an idle poll loop visible in the logs without an INFO line per 116 // poll: the first empty poll after start-up or after a claim logs at INFO, 117 // later ones at DEBUG, with an INFO reminder every idleReportInterval. 118 type idleLog struct { 119 since time.Time 120 lastReport time.Time 121 } 122 123 func (l *idleLog) onEmptyPoll(ctx context.Context, log *slog.Logger, now time.Time) { 124 switch { 125 case l.since.IsZero(): 126 l.since, l.lastReport = now, now 127 log.InfoContext(ctx, "idle; polling for work") 128 case now.Sub(l.lastReport) >= idleReportInterval: 129 l.lastReport = now 130 log.InfoContext(ctx, fmt.Sprintf("still polling; idle for %s", now.Sub(l.since).Round(time.Second))) 131 default: 132 log.DebugContext(ctx, "poll returned no work") 133 } 134 } 135 136 func (l *idleLog) onClaim() { l.since = time.Time{} } 137 138 // WorkPollerOptions configures a WorkPoller. 139 type WorkPollerOptions struct { 140 // EnvironmentID is the self-hosted environment to claim work from. 141 // Required. 142 EnvironmentID string 143 144 // EnvironmentKey is the bearer token used to authorize every work.* 145 // call the poller issues — Poll, Ack and Stop. Required. 146 EnvironmentKey string 147 148 // WorkerID is a stable identifier reported back to the server for 149 // observability and Redis consumer-group routing. Defaults to 150 // "<os.Hostname()>-<random hex>". 151 WorkerID string 152 153 // RequestOptions are applied to every work.* call the poller issues — 154 // Poll, Ack and Stop — on top of the environment-key auth and 155 // x-stainless-helper telemetry the poller adds itself. Use it for a 156 // proxy/custom header or a base-URL override. These options are applied 157 // first, so the poller's own environment-key auth and helper header take 158 // precedence and cannot be clobbered by a caller option. 159 RequestOptions []option.RequestOption 160 161 // Drain controls what the poller does when the work queue is empty. 162 // When false (default) the poller long-polls forever, sleeping with 163 // jitter between empty polls — the long-running-runner shape. When true 164 // the poller returns as soon as a poll comes back empty: Next returns 165 // false and Err stays nil (normal termination, same as ctx 166 // cancellation), so a webhook-driven dispatcher can drain the queue and 167 // respond instead of blocking. Pair with BlockMs set to param.Null for a 168 // single non-blocking pass. 169 Drain bool 170 171 // AutoStop controls whether the poller posts work.stop itself for each 172 // yielded item once the consumer is done with it (the next Next call or 173 // Close). Omitted (zero value) means on: the poller owns the whole 174 // work-item lifecycle. Set param.NewOpt(false) when the consumer stops or 175 // releases each item itself — as [EnvironmentWorker.Run] does, and as a 176 // dispatcher handing items to another process should — so the item is 177 // not stopped twice or taken from whoever owns it by then. 178 // 179 // Orthogonal to Drain: AutoStop is per-item lifecycle, Drain is loop 180 // termination. The long-running runner is Drain false with AutoStop on 181 // (the defaults); drain-and-dispatch is Drain true with AutoStop off. 182 AutoStop param.Opt[bool] 183 184 // BlockMs is the long-poll block_ms forwarded to work.poll — how long 185 // the server holds an empty poll open. Three states: 186 // - omitted (zero value): the poller uses the default of 999ms (the 187 // server caps block_ms at 999). 188 // - param.NewOpt(n): request block_ms=n. 189 // - param.Null[int64](): omit block_ms entirely for a non-blocking 190 // poll (the server rejects an explicit 0). Drain callers usually 191 // want this so the final empty poll returns immediately. 192 BlockMs param.Opt[int64] 193 194 // ReclaimOlderThanMs is forwarded to work.poll: reclaim un-ack'd work 195 // older than this many milliseconds. Omitted (zero value) by default so 196 // no reclaim is requested. Useful in drain mode so a dead runner's work 197 // re-surfaces on the next poll. Set with param.NewOpt(n). 198 ReclaimOlderThanMs param.Opt[int64] 199 200 // Logger receives non-fatal warnings (poll backoff, decode/ack skip, 201 // stop failure). Defaults to slog.Default(). 202 Logger *slog.Logger 203 } 204 205 // WorkPoller long-polls an environment's work queue, claims items, and posts 206 // ack — yielding each claimed [anthropic.BetaSelfHostedWork] to the consumer 207 // via Next/Current. After the consumer finishes with a yielded item (the next 208 // Next call or Close), the poller posts Stop for that work item unless 209 // [WorkPollerOptions.AutoStop] is off. Poll, Ack and Stop are all authorized 210 // with the environment key. 211 // 212 // A WorkPoller is NOT safe for concurrent use. All methods must be called 213 // from a single goroutine. Always pair construction with a Close, ideally 214 // via defer, so the final yielded work item is reliably stopped. 215 // 216 // Typical usage: 217 // 218 // poller := environments.NewWorkPoller(ctx, client, environments.WorkPollerOptions{ 219 // EnvironmentID: envID, 220 // EnvironmentKey: environmentKey, 221 // }) 222 // defer poller.Close() 223 // for poller.Next() { 224 // work := poller.Current() 225 // // handle work 226 // } 227 // if err := poller.Err(); err != nil { 228 // log.Fatal(err) 229 // } 230 type WorkPoller struct { 231 ctx context.Context 232 client anthropic.Client 233 opts WorkPollerOptions 234 log *slog.Logger 235 236 current *anthropic.BetaSelfHostedWork 237 err error // first construction-time or per-call error, or nil 238 pendingStop func() // runs on next Next or Close; nil between yields 239 failures int // consecutive poll failures, for poll backoff 240 discards int // consecutive unprocessable items, for discard backoff 241 closed bool 242 autoStop bool 243 244 idle idleLog 245 // now is the clock for idle logging and emptyPollWait the base wait between 246 // empty polls (jittered up to three times it); tests replace both. 247 now func() time.Time 248 emptyPollWait time.Duration 249 } 250 251 // NewWorkPoller returns a WorkPoller bound to ctx and client. WorkerID 252 // defaults to "<os.Hostname()>-<random hex>"; Logger defaults to 253 // slog.Default(). 254 // 255 // Required option fields are validated here: if EnvironmentID or 256 // EnvironmentKey is empty, Next returns false on the first call and Err 257 // returns the corresponding error. The returned pointer is never nil so 258 // `defer poller.Close()` is always safe. 259 func NewWorkPoller(ctx context.Context, client anthropic.Client, opts WorkPollerOptions) *WorkPoller { 260 log := opts.Logger 261 if log == nil { 262 log = slog.Default() 263 } 264 if opts.WorkerID == "" { 265 opts.WorkerID = defaultWorkerID(log) 266 } 267 log = log.With( 268 slog.String("component", "work-poller"), 269 slog.String("environment_id", opts.EnvironmentID), 270 ) 271 p := &WorkPoller{ 272 ctx: ctx, 273 client: client, 274 opts: opts, 275 log: log, 276 autoStop: opts.AutoStop.Or(true), 277 now: time.Now, 278 emptyPollWait: time.Second, 279 } 280 switch { 281 case opts.EnvironmentID == "": 282 p.err = errors.New("environments: WorkPollerOptions.EnvironmentID is required") 283 case opts.EnvironmentKey == "": 284 p.err = errors.New("environments: WorkPollerOptions.EnvironmentKey is required") 285 } 286 return p 287 } 288 289 // Next advances the poller. Returns true if a work item is now available 290 // via Current. Returns false when the bound context is cancelled, Close 291 // has been called, or a non-retryable error occurred (check Err). 292 // 293 // Each Next call first runs the deferred Stop for the previously yielded 294 // work item (if any), then long-polls until a claimable item arrives and 295 // acks it. An ack failure causes the item to be force-stopped and skipped, 296 // and polling continues. 297 func (p *WorkPoller) Next() bool { 298 p.runPendingStop() 299 if p.err != nil || p.closed { 300 return false 301 } 302 303 // The environment key authorizes every work.* call the poller issues: 304 // Poll, Ack and Stop. helperReqOpts also clears the parent client's 305 // default X-Api-Key so it doesn't ride alongside the bearer credential. 306 // Caller-supplied RequestOptions are applied first so a proxy/custom 307 // header reaches Poll/Ack/Stop while the poller's own X-Api-Key delete, 308 // environment-key auth and helper header (appended last) still win. 309 helperOpts, err := helperReqOpts(p.opts.EnvironmentKey, stainlessheader.EnvironmentsWorkPoller) 310 if err != nil { 311 // NewWorkPoller already validates EnvironmentKey at construction 312 // (see line 240-245), so reaching here means the validation was 313 // bypassed by a future code path; surface the failure on Err() 314 // and end the iterator rather than firing requests with the 315 // parent client's credentials. 316 p.err = err 317 return false 318 } 319 reqOpts := make([]option.RequestOption, 0, len(p.opts.RequestOptions)+len(helperOpts)) 320 reqOpts = append(reqOpts, p.opts.RequestOptions...) 321 reqOpts = append(reqOpts, helperOpts...) 322 323 // block_ms: an explicit value is forwarded as-is, an explicit null omits 324 // the param (non-blocking poll), and the omitted/zero case falls back to 325 // the default 999 so the historical behaviour is preserved. 326 pollParams := anthropic.BetaEnvironmentWorkPollParams{ 327 AnthropicWorkerID: param.NewOpt(p.opts.WorkerID), 328 } 329 switch { 330 case p.opts.BlockMs.Valid(): 331 pollParams.BlockMs = p.opts.BlockMs 332 case param.IsNull(p.opts.BlockMs): 333 // non-blocking poll: leave BlockMs omitted from the request 334 default: 335 pollParams.BlockMs = param.NewOpt(int64(pollBlockMillis)) 336 } 337 if p.opts.ReclaimOlderThanMs.Valid() { 338 pollParams.ReclaimOlderThanMs = p.opts.ReclaimOlderThanMs 339 } 340 341 for { 342 if p.ctx.Err() != nil { 343 // ctx cancellation is normal termination, not an error — same 344 // convention as io.EOF for Go iterators. 345 return false 346 } 347 348 work, err := p.client.Beta.Environments.Work.Poll( 349 p.ctx, 350 p.opts.EnvironmentID, 351 pollParams, 352 reqOpts..., 353 ) 354 if err != nil { 355 if p.ctx.Err() != nil { 356 return false 357 } 358 // A bad environment key or a missing environment is a 4xx that 359 // will never succeed on retry — surface it instead of backing 360 // off forever, matching the heartbeat/stream sibling loops. 361 if isFatal4xx(err) { 362 p.log.ErrorContext(p.ctx, "poll hit permanent 4xx; stopping", 363 slog.Any("error", err)) 364 p.err = fmt.Errorf("environments: poll failed: %w", err) 365 return false 366 } 367 p.failures++ 368 // Jittered backoff so a fleet of workers recovering from the 369 // same blip don't synchronise their retries. 370 base := backoff(p.failures) 371 d := jitter(base/2, base) 372 p.log.WarnContext(p.ctx, "poll failed, backing off", 373 slog.Any("error", err), slog.Duration("sleep", d)) 374 sleep(p.ctx, d) 375 continue 376 } 377 p.failures = 0 378 379 if work == nil || work.ID == "" { 380 if p.opts.Drain { 381 // Queue empty and Drain set: end iteration normally (Err 382 // stays nil) rather than sleeping and re-polling, the same 383 // way ctx cancellation ends it. 384 p.log.InfoContext(p.ctx, "work queue drained") 385 return false 386 } 387 p.idle.onEmptyPoll(p.ctx, p.log, p.now()) 388 sleep(p.ctx, jitter(p.emptyPollWait, 3*p.emptyPollWait)) 389 continue 390 } 391 p.idle.onClaim() 392 393 log := p.log.With(slog.String("work_id", work.ID), 394 slog.String("work_type", string(work.Data.Type))) 395 396 if _, err := p.client.Beta.Environments.Work.Ack( 397 p.ctx, 398 work.ID, 399 anthropic.BetaEnvironmentWorkAckParams{ 400 EnvironmentID: p.opts.EnvironmentID, 401 }, 402 reqOpts..., 403 ); err != nil { 404 // The claim could not be confirmed — discard the item rather than 405 // leaving it dangling, and back off before polling again. 406 log.ErrorContext(p.ctx, "ack failed, discarding work item", 407 slog.Any("error", err)) 408 p.discardUnprocessable(work, reqOpts, log) 409 continue 410 } 411 log.InfoContext(p.ctx, "claimed work") 412 413 p.discards = 0 414 p.current = work 415 if p.autoStop { 416 p.pendingStop = p.makeStopClosure(work.ID, work.EnvironmentID, reqOpts, log) 417 } 418 return true 419 } 420 } 421 422 // Current returns the most recent claimed work item. Only valid after Next 423 // returned true and before the next Next call. 424 func (p *WorkPoller) Current() *anthropic.BetaSelfHostedWork { 425 return p.current 426 } 427 428 // Err returns the first non-retryable error, or nil if iteration ended 429 // normally (consumer break, ctx cancellation, or Close). Construction-time 430 // option errors are surfaced here on the first Next call. 431 func (p *WorkPoller) Err() error { 432 return p.err 433 } 434 435 // Close runs the deferred Stop for the last yielded work item (if any) 436 // and marks the poller closed. Safe to call multiple times; subsequent 437 // Next calls return false. Always returns nil — the signature satisfies 438 // io.Closer so callers can `defer poller.Close()` uniformly. 439 func (p *WorkPoller) Close() error { 440 if p.closed { 441 return nil 442 } 443 p.closed = true 444 p.runPendingStop() 445 return nil 446 } 447 448 // All returns a Go 1.23 range-over-func iterator yielding each claimed 449 // [anthropic.BetaSelfHostedWork]. On the final iteration (or on early break) 450 // err carries the value of Err. 451 // 452 // for work, err := range poller.All() { 453 // if err != nil { return err } 454 // // ... 455 // } 456 func (p *WorkPoller) All() iter.Seq2[*anthropic.BetaSelfHostedWork, error] { 457 return func(yield func(*anthropic.BetaSelfHostedWork, error) bool) { 458 for p.Next() { 459 if !yield(p.Current(), nil) { 460 return 461 } 462 } 463 if err := p.Err(); err != nil { 464 yield(nil, err) 465 } 466 } 467 } 468 469 func (p *WorkPoller) runPendingStop() { 470 if p.pendingStop == nil { 471 return 472 } 473 stop := p.pendingStop 474 p.pendingStop = nil 475 stop() 476 } 477 478 func (p *WorkPoller) makeStopClosure(workID, envID string, reqOpts []option.RequestOption, log *slog.Logger) func() { 479 return func() { 480 // Fresh context so a cancelled caller ctx doesn't skip the Stop. 481 stopCtx, cancel := context.WithTimeout(context.Background(), stopTimeout) 482 defer cancel() 483 stopErr := stopWork(stopCtx, p.client, workID, 484 anthropic.BetaEnvironmentWorkStopParams{EnvironmentID: envID}, 485 reqOpts...) 486 if stopErr != nil && !isStatus(stopErr, 409) { 487 log.WarnContext(stopCtx, "stop failed", slog.Any("error", stopErr)) 488 } 489 } 490 } 491 492 // stopWork posts work.Stop and discards the response body. 493 // 494 // TODO: remove this helper once either (a) the OpenAPI spec for 495 // work.Stop is updated to declare a 204 No Content response (currently 496 // it declares a BetaSelfHostedWork JSON body that the server never 497 // sends), or (b) the Go SDK's internal/requestconfig short-circuits 204 498 // responses the way the TypeScript SDK does in src/internal/parse.ts. 499 // Today the server returns 204 with no body / no Content-Type, and the 500 // strict Go decoder errors with 501 // 502 // "expected destination type of 'string' or '[]byte' for responses 503 // with content-type '' that is not 'application/json'" 504 // 505 // for what is actually a successful call. We work around it by 506 // rebinding the response destination to **http.Response via 507 // WithResponseBodyInto, which trips the bypass in internal/requestconfig 508 // (the executor hands the body off without parsing). TS and Python 509 // never hit this because their decoders handle 204/empty bodies 510 // gracefully; only Go is strict here. 511 func stopWork(ctx context.Context, client anthropic.Client, workID string, params anthropic.BetaEnvironmentWorkStopParams, opts ...option.RequestOption) error { 512 var raw *http.Response 513 opts = append(opts, option.WithResponseBodyInto(&raw)) 514 _, err := client.Beta.Environments.Work.Stop(ctx, workID, params, opts...) 515 if raw != nil && raw.Body != nil { 516 _ = raw.Body.Close() 517 } 518 return err 519 } 520 521 // discardUnprocessable best-effort force-stops a claimed work item the poller 522 // cannot hand to a consumer — its ack failed — so the item does not dangle 523 // and get redelivered, then backs off before the next poll so a persistently 524 // bad item cannot hot-loop the poller. reqOpts carries the environment key. 525 func (p *WorkPoller) discardUnprocessable(work *anthropic.BetaSelfHostedWork, reqOpts []option.RequestOption, log *slog.Logger) { 526 stopCtx, cancel := context.WithTimeout(context.Background(), stopTimeout) 527 defer cancel() 528 if err := stopWork(stopCtx, p.client, work.ID, 529 anthropic.BetaEnvironmentWorkStopParams{ 530 EnvironmentID: work.EnvironmentID, 531 BetaSelfHostedWorkStopRequest: anthropic.BetaSelfHostedWorkStopRequestParam{ 532 Force: param.NewOpt(true), 533 }, 534 }, 535 reqOpts..., 536 ); err != nil && !isStatus(err, 409) { 537 log.WarnContext(p.ctx, "force-stop of unprocessable work item failed", 538 slog.Any("error", err)) 539 } 540 541 p.discards++ 542 base := backoff(p.discards) 543 d := jitter(base/2, base) 544 log.WarnContext(p.ctx, "backing off after unprocessable work item", 545 slog.Duration("sleep", d)) 546 sleep(p.ctx, d) 547 } 548 549 // Compile-time assertion that *WorkPoller satisfies io.Closer. 550 var _ io.Closer = (*WorkPoller)(nil) 551 552 func backoff(n int) time.Duration { 553 if n <= 0 { 554 return time.Second 555 } 556 if n > 6 { 557 return pollBackoffCap 558 } 559 if d := time.Duration(1<<n) * time.Second; d <= pollBackoffCap { 560 return d 561 } 562 return pollBackoffCap 563 } 564 565 func jitter(low, high time.Duration) time.Duration { 566 if high <= low { 567 return low 568 } 569 return low + time.Duration(rand.Int64N(int64(high-low))) 570 } 571 572 func sleep(ctx context.Context, d time.Duration) { 573 t := time.NewTimer(d) 574 defer t.Stop() 575 select { 576 case <-ctx.Done(): 577 case <-t.C: 578 } 579 } 580 581 // defaultWorkerID returns a process-unique worker id. The control plane uses 582 // it for observability and Redis consumer-group routing and documents that it 583 // must be unique, so even when os.Hostname succeeds we append a random suffix 584 // rather than reusing a bare (and possibly shared) hostname; if Hostname fails 585 // the random suffix alone still keeps the id unique. 586 func defaultWorkerID(log *slog.Logger) string { 587 var b [8]byte 588 if _, err := crand.Read(b[:]); err != nil { 589 log.Debug("crypto/rand failed generating worker id suffix", slog.Any("error", err)) 590 } 591 suffix := hex.EncodeToString(b[:]) 592 host, err := os.Hostname() 593 if err != nil || host == "" { 594 if err != nil { 595 log.Debug("os.Hostname failed; using random-only worker id", slog.Any("error", err)) 596 } 597 return "anthropic-sdk-go-runner-" + suffix 598 } 599 return host + "-" + suffix 600 } 601 602 func isStatus(err error, code int) bool { 603 var apiErr *anthropic.Error 604 return errors.As(err, &apiErr) && apiErr.StatusCode == code 605 } 606 607 // isFatal4xx reports whether err is a client error that will not succeed 608 // on retry. 408 (timeout), 409 (conflict) and 429 (rate-limited) are 609 // excluded so callers back off rather than tear down, mirroring the statuses 610 // the core client itself retries (internal/requestconfig). Keep in step with 611 // anthropic.isFatal4xxStatus, the session tool runner's copy. 612 func isFatal4xx(err error) bool { 613 var apiErr *anthropic.Error 614 if !errors.As(err, &apiErr) { 615 return false 616 } 617 c := apiErr.StatusCode 618 return c >= 400 && c < 500 && c != 408 && c != 409 && c != 429 619 }