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  }