fix(#4060): add bounded jittered backoff to StartableToolSet retry path - #4062
fix(#4060): add bounded jittered backoff to StartableToolSet retry path#4062aheritier wants to merge 1 commit into
Conversation
…rt, align bounds Addresses all blocking and should-fix findings from the aheritier review on PR #4062: [blocking #1 + #2] Generic classifier with HTTP-status precedence: - Add modelerrors.RetryableHTTPStatus(err) — catches any error carrying a retryable HTTP status (429/408/5xx) via *StatusError or message regex, without string-pattern heuristics ('connection refused' stays non-retryable). - startBackoffRetryable becomes: return err != nil && RetryableHTTPStatus(err). A StatusError{429} coexisting with context.DeadlineExceeded now arms the gate (HTTP wins), fixing the deadline-masks-rate-limit race. [blocking #3] Bounds aligned with remediation plan: - base = 15s, cap = 5min (was 1s/30s). - Additive jitter [d, 1.2d] (was equal jitter [d/2, d]), guaranteeing the full nominal wait is always respected. [blocking #4] Gate enforced only in the TryStart path: - Move gate check from startLocked into new tryStartLocked (called by TryStart/TryStartWithTimeout only). - Start() calls startLocked directly — mcpcatalog enable and skill sub-session startup are never delayed. [should-fix #5] External recovery via StartReporter: - tryStartLocked checks reporter.IsStarted() when started==false; a live reporter (e.g. after /toolset-restart) clears the gate and latches the wrapper without calling the underlying Start. - New test: TestStartableToolSet_ExternalRecoveryClearsBackoffGate. [should-fix #6] Exported constructor options for cross-package tests: - NewStartable(ts, opts...) with StartableOption, WithStartRetryJitter, WithStartRetryClock. - nowFn() clock seam; zero-value StartableToolSet still usable. [should-fix #7] Concurrent and at-boundary tests: - TestStartableToolSet_BackoffNoDoubleStartWithinWindow: 20 goroutines calling TryStart, assert underlying Start invoked exactly once. - TestStartableToolSet_BackoffAtBoundary: fake clock, gate open at expiry. [optional] Stale comment name in BackoffDormantForPlainErrors fixed. Also: - RetryableHTTPStatus test cases include plain-text regex fallback. - ExportedSetClock removed (unused; WithStartRetryClock preferred). - All gating tests converted from s.Start() to s.TryStart(). - Jitter-bounds assertions updated to [nominal, 1.2×nominal]. PR2 (#4065) will need rebasing and test updates after this lands.
[blocking] partial-start comment corrected; known limitation for code-mode composites documented with reference to follow-up issue #4067 [SF1] startBackoffRetryable requires *StatusError — regex fallback excluded to prevent false positives on port numbers and chunk counters; RetryableHTTPStatus doc corrected to describe the actual regex-fallback behaviour [SF2] TestStartableToolSet_BlockingStartSkipsGate: arm gate, assert blocking Start() invokes underlying (not gated), assert TryStart() is gated [SF3] tryStartLocked: reporter-adoption guarded by !startBackoffUntil.IsZero() — fix is now scoped to gated state; non-gated TryStart semantics unchanged [SF4] stale 'next turn' log messages updated in agent.go and mcp.go [optional] ExportedSetJitter removed (dead code; WithStartRetryJitter preferred)
…rt, align bounds Addresses all blocking and should-fix findings from the aheritier review on PR #4062: [blocking #1 + #2] Generic classifier with HTTP-status precedence: - Add modelerrors.RetryableHTTPStatus(err) — catches any error carrying a retryable HTTP status (429/408/5xx) via *StatusError or message regex, without string-pattern heuristics ('connection refused' stays non-retryable). - startBackoffRetryable becomes: return err != nil && RetryableHTTPStatus(err). A StatusError{429} coexisting with context.DeadlineExceeded now arms the gate (HTTP wins), fixing the deadline-masks-rate-limit race. [blocking #3] Bounds aligned with remediation plan: - base = 15s, cap = 5min (was 1s/30s). - Additive jitter [d, 1.2d] (was equal jitter [d/2, d]), guaranteeing the full nominal wait is always respected. [blocking #4] Gate enforced only in the TryStart path: - Move gate check from startLocked into new tryStartLocked (called by TryStart/TryStartWithTimeout only). - Start() calls startLocked directly — mcpcatalog enable and skill sub-session startup are never delayed. [should-fix #5] External recovery via StartReporter: - tryStartLocked checks reporter.IsStarted() when started==false; a live reporter (e.g. after /toolset-restart) clears the gate and latches the wrapper without calling the underlying Start. - New test: TestStartableToolSet_ExternalRecoveryClearsBackoffGate. [should-fix #6] Exported constructor options for cross-package tests: - NewStartable(ts, opts...) with StartableOption, WithStartRetryJitter, WithStartRetryClock. - nowFn() clock seam; zero-value StartableToolSet still usable. [should-fix #7] Concurrent and at-boundary tests: - TestStartableToolSet_BackoffNoDoubleStartWithinWindow: 20 goroutines calling TryStart, assert underlying Start invoked exactly once. - TestStartableToolSet_BackoffAtBoundary: fake clock, gate open at expiry. [optional] Stale comment name in BackoffDormantForPlainErrors fixed. Also: - RetryableHTTPStatus test cases include plain-text regex fallback. - ExportedSetClock removed (unused; WithStartRetryClock preferred). - All gating tests converted from s.Start() to s.TryStart(). - Jitter-bounds assertions updated to [nominal, 1.2×nominal]. PR2 (#4065) will need rebasing and test updates after this lands.
Regression suite for the backoff gate introduced in PR1 (#4062). Test files: - pkg/tools/startable_backoff_regression_test.go: 9 consumer-shaped regression tests using fakes modelled on real RAG/MCP/LSP error shapes — RAG-shaped failure+recovery, MCP/LSP compatibility (fail-fast, no backoff), HTTP 408 gate, concurrent starts no-multiplication, no timer/goroutine leak, cancellation no-window, jitter de-synchronization, already-started no-restart. - pkg/tools/builtin/rag/rag_backoff_test.go: 2 real-toolset RAG tests using rag.New + countingStatusErrStrategy; proves the real toolset's StatusError wrapping chain is traversable by errors.As and that plain errors fail fast. Docs: - docs/tools/rag/index.md: authoritative 'Indexing failures, retries and backoff' section — retry policy (1s base, 30s cap, equal jitter), what triggers backoff (429, 408, 5xx) vs fail-fast (other 4xx, cancellation), operational impact and troubleshooting guidance. - docs/tools/mcp/index.md: short note under Lifecycle clarifying MCP local startup failures fail fast; links to RAG page for full policy. - docs/tools/lsp/index.md: matching note under Auto-Restart and Lifecycle. No production code changes.
ce13fa7 to
ab320cf
Compare
[blocking] partial-start comment corrected; known limitation for code-mode composites documented with reference to follow-up issue #4067 [SF1] startBackoffRetryable requires *StatusError — regex fallback excluded to prevent false positives on port numbers and chunk counters; RetryableHTTPStatus doc corrected to describe the actual regex-fallback behaviour [SF2] TestStartableToolSet_BlockingStartSkipsGate: arm gate, assert blocking Start() invokes underlying (not gated), assert TryStart() is gated [SF3] tryStartLocked: reporter-adoption guarded by !startBackoffUntil.IsZero() — fix is now scoped to gated state; non-gated TryStart semantics unchanged [SF4] stale 'next turn' log messages updated in agent.go and mcp.go [optional] ExportedSetJitter removed (dead code; WithStartRetryJitter preferred)
ab320cf to
6310940
Compare
501cab1 to
7d9852d
Compare
7d9852d to
b318089
Compare
This comment was marked as resolved.
This comment was marked as resolved.
b318089 to
1991438
Compare
This comment was marked as resolved.
This comment was marked as resolved.
1991438 to
fe986e7
Compare
This comment was marked as outdated.
This comment was marked as outdated.
fe986e7 to
e0b4aab
Compare
08b9a9e to
c82351f
Compare
Refs #4060 (partial): RAG semantic-embeddings indexing triggered a rate-limit retry storm — repeated toolset-start attempts had no pacing after a 429 from the embedding provider (the 15s/5min gate is the partial fix; DefaultStartTimeout and chunk-level checkpointing are deferred). Implementation: - modelerrors.RetryableHTTPStatus(err): HTTP-status classifier that recognises 429, 408, and 5xx via *StatusError first, then falls back to statusCodeRegex. The toolset gate pre-filters to *StatusError via errors.As before calling it, so port numbers and chunk counts in plain error strings cannot arm the gate. - pkg/tools/startable_backoff.go: bounded exponential backoff with additive 0-20% jitter (base=15s, cap=5min, delay∈[d,1.2d]). - Gate in tryStartLocked (TryStart/TryStartWithTimeout only): blocking Start() bypasses it so mcpcatalog enable and skill startup are immediate. - Gate adopts a live StartReporter after /toolset-restart without waiting for the window to expire. - Wrap embedding errors via oaistream.WrapOpenAIError at openai/client.go and dmr/embed.go so a 429 from the embedding provider surfaces as *StatusError and correctly arms the gate. - WithStartRetryJitter / WithStartRetryClock options via variadic NewStartable for deterministic test control. - Stale 'retry on next turn' log messages updated in agent.go/mcp.go. - Partial-start exemption documented (code-mode composites remain unpaced; follow-up at issue #4067). Tests (same commit, covering the above): - startable_backoff_test.go: unit tests for the gate (gate fires on 429/408/5xx StatusError, not on plain text / context errors, blocking Start() ungated, concurrency, jitter bounds) - startable_backoff_regression_test.go: consumer-shaped regression suite (RAG/MCP/LSP error shapes, no-goroutine/timer leak, latch) - rag_backoff_test.go: real-toolset integration test via rag.New + fake clock Docs: - docs/tools/rag/index.md: 'Indexing failures, retries and backoff' section with trigger table, parameters, and troubleshooting. - docs/tools/mcp/index.md, docs/tools/lsp/index.md: lifecycle notes confirming local startup failures fail fast. Scope: DefaultStartTimeout (30s) unchanged — deferred.
c82351f to
e4ae8fd
Compare
aheritier
left a comment
There was a problem hiding this comment.
🤖 Automated implementer agent — this comment was posted by the implementer bot from Docker Agentic Platform, not by a human developer
Re-reviewed at head e4ae8fd7 (identical content to the previously-reviewed c82351f6, force-pushed as a rebase onto a fresher main — same 18 files, same 1690/13 insertion/deletion counts, commit message unchanged; verified the rebase was mechanically conflict-free since the only intervening main commit, #4071, touched only CHANGELOG.md). CI is green: 16 checks pass, 3 skipping (post-merge publish jobs), 0 failing. mergeable=MERGEABLE; mergeStateStatus=BLOCKED solely on the missing required approving review.
This addresses my previous review (fe986e7, #pullrequestreview-5052930334). Going through each finding against the current head:
[blocking] Retry-After override branch — now covered
Four dedicated tests exercise every sub-case in pkg/tools/startable_backoff_test.go:
TestStartableToolSet_RetryAfterHonoured(line 613): hint > computed delay → window extends to the hint exactly.TestStartableToolSet_RetryAfterBelowComputed(line 696): hint (5s) < computed delay (30s at attempt 2) → hint ignored, computed window holds.TestStartableToolSet_RetryAfterCapAtMax(line 730): a 24h hint is capped atstartBackoffMax(5min).TestStartableToolSet_RetryAfterJittered(line 656): 60 independent gates armed with the same hint, probed at the midpoint of[hint, 1.2×hint]— confirms windows are spread across the jitter band, not all set to the exact hint.
go tool cover on ./pkg/tools/ (86.4%) now shows setStartBackoff (pkg/tools/startable.go:466) at 100% and no zero-coverage lines remain in startable.go or startable_backoff.go — the previously-unexecuted override branch (was lines 476-477, 0 executions) is exercised. All four tests pass:
--- PASS: TestStartableToolSet_RetryAfterHonoured
--- PASS: TestStartableToolSet_RetryAfterBelowComputed
--- PASS: TestStartableToolSet_RetryAfterCapAtMax
--- PASS: TestStartableToolSet_RetryAfterJittered
[should-fix] Retry-After jitter bypass — fixed
setStartBackoff (pkg/tools/startable.go:476-481) now applies the same jitter function to the Retry-After branch as the computed-delay path:
if hint := retryAfterHint(err); hint > delay {
if s.startJitter != nil {
delay = s.startJitter(min(hint, startBackoffMax))
} else {
delay = additiveJitter(min(hint, startBackoffMax))
}
}TestStartableToolSet_RetryAfterJittered statistically confirms the spread (not all 60 samples land on the same side of the midpoint).
[should-fix] Retry-After undocumented — fixed
docs/tools/rag/index.md:212 now has a "Retry-After override" bullet under "Retry policy and parameters", stating the hint overrides the computed delay, is capped at the 5-minute maximum, and gets the same additive jitter. This also incidentally closes the optional finding below about the 5min→6min ceiling — the same section's "Maximum delay" line now reads "up to ~6 minutes (5-minute cap plus up to 20% additive jitter)".
[should-fix] Closes #4060 → fixed
The commit message and PR description both now read Refs #4060 (partial — ...). Squash-merging this PR will no longer auto-close #4060.
[should-fix] PR body "Changed files" table incomplete — fixed
The table now lists all 18 files in the diff (verified against git show --stat on the current head), including the previously-omitted docs/tools/rag/index.md, docs/tools/mcp/index.md, docs/tools/lsp/index.md, docs/community/troubleshooting/index.md, pkg/tools/startable_backoff_regression_test.go, pkg/tools/builtin/rag/rag_backoff_test.go, and pkg/model/provider/dmr/embed_test.go. The Design section's embedding-wrap description is also now precise: it names oaistream.WrapOpenAIError (not the imprecise WrapHTTPError) and explains that it wraps *openaisdk.Error specifically.
[optional] "5 minutes" → "up to 6 minutes" — fixed (see above)
[optional] Only DMR embed site tested — fixed
pkg/model/provider/openai/embed_test.go now exists with TestCreateBatchEmbedding_429SurfacesAsStatusError and a 5xx equivalent, mirroring the DMR test. Both embedding-wrap sites are now pinned.
Re-validation on the current head
go build ./... # clean
go test ./pkg/tools/... ./pkg/modelerrors/... ./pkg/model/provider/... # all green
golangci-lint run ./pkg/tools/ ./pkg/tools/mcp/ ./pkg/modelerrors/ # 0 issues
(-race isn't available in this environment — no gcc for cgo — so I could not re-run the race-detector pass from my previous review; the non-race suite is green and nothing in this delta touches concurrency-sensitive code paths beyond what was already reviewed.)
All previously-blocking and should-fix findings are resolved. Filing as a comment rather than a formal approval only because I authored the PR and GitHub blocks self-approval, same as every prior round.
Refs #4060 (partial — MCP/LSP startup errors and RAG 5xx/408 pacing deferred to follow-up)
What
Adds bounded, cancellable, jittered exponential backoff to
StartableToolSet's non-blocking start path. A rate-limited embedding-provider 429 that previously triggered a full concurrent re-index on every agent turn is now paced to at most one retry per 15 s – 5 min window.Design
Gate location —
tryStartLocked(TryStart/TryStartWithTimeout only). BlockingStart()bypasses the gate so mcpcatalog enable and skill sub-session startup always get a fresh attempt.Classifier —
startBackoffRetryable. Requires a*modelerrors.StatusErrorin the error chain viaerrors.As. Port numbers and chunk-progress counters in plain error strings cannot arm the gate.Bounds — base 15 s, cap 5 min, additive jitter
[d, 1.2d]. The additive floor guarantees the full nominal wait; jitter de-synchronises concurrent toolset sources.Retry-After. When the 429 response carries a
Retry-Afterheader, that hint overrides the computed delay (capped at 5 min, with the same additive jitter applied to avoid re-synchronisation).Embedding-provider gap fixed.
openai/client.goanddmr/embed.gonow calloaistream.WrapOpenAIErroron their embedding errors.WrapOpenAIErrorwraps*openaisdk.Errorin*modelerrors.StatusErrorcarrying the HTTP status code, so a 429 from the embedding provider reaches the gate as*StatusErrorand arms it.Known limitation. Code-mode composites (
codemode.Wrap) returnPartialStartErroron a partial failure; the partial-start branch resets the gate and leaves the failed-subset retry unpaced. Tracked in #4067.Changed files
pkg/modelerrors/modelerrors.goRetryableHTTPStatus(err)classifierpkg/modelerrors/modelerrors_test.gopkg/tools/startable_backoff.gocomputeStartBackoff,startBackoffRetryable,additiveJitter,retryAfterHintpkg/tools/startable_backoff_test.gopkg/tools/startable_backoff_regression_test.gopkg/tools/startable.gotryStartLocked,StartableOption,WithStartRetryJitter,WithStartRetryClock, variadicNewStartablepkg/tools/export_test.gopkg/tools/builtin/rag/rag_backoff_test.gorag.New+ fake clockpkg/model/provider/openai/client.gooaistream.WrapOpenAIErrorpkg/model/provider/openai/embed_test.go*StatusErrorchain; 5xx equivalentpkg/model/provider/dmr/embed.gooaistream.WrapOpenAIErrorpkg/model/provider/dmr/embed_test.go*StatusErrorchainpkg/agent/agent.gopkg/tools/mcp/mcp.godocs/tools/rag/index.mddocs/tools/mcp/index.mddocs/tools/lsp/index.mddocs/community/troubleshooting/index.md