feat(runs): retry transient provider errors with backoff - #624
Open
yzxcj797 wants to merge 1 commit into
Open
Conversation
The pi harness classified every provider stop-error as NonRetryableTurnError — a rate_limit_error took the same terminal path as an authentication_error, so the runs worker (retry: !(err instanceof NonRetryableTurnError)) parked the run after one attempt. On the reporter's deployment every pi failure got exactly 1 attempt; the one run that ever used its 3 attempts was on the claude harness, which only marks its wall-clock timeout non-retryable (yc-software#602). Two changes: - pi error classification: transient provider failures (rate_limit_error, overloaded_error, timeout_error, api_error, server_error, and siblings) become plain errors so the worker retries them; permanent ones (authentication, permission, invalid request) and non-JSON errors keep the non-retryable class — a second attempt would fail identically. The formatted message is unchanged in both classes; only the class carries the decision. - error-retry backoff: retries used to be immediate (requeue to pending, worker polls at 50ms), so maxAttempts requests hit a rate-limited provider back-to-back, feeding the limit that caused the failure. An ERROR-driven requeue now waits retryBackoffMs(errorAttempts) (5s exponential, capped 60s) before it is claimable, via the pending row's lease_expires_at as a not-before honored by both claim paths. Lease-expiry requeues (suspected worker crashes) keep their immediate retry and their own poison-pill budget. The memory run store gains a retryBackoffMs override so tests can keep their immediate cadence. The failover (section 3) and /healthz (section 4) halves of yc-software#602 are larger design decisions and are not included. Fixes the pi-retry/backoff sections of yc-software#602
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the pi-retry and cron-adjacent backoff sections of #602 (sections 1 and the retry-cadence half of 2). Failover (3) and /healthz (4) are larger design decisions, deliberately not included.
Section 1: every pi provider error was terminal
piAssistantErrorfires on any assistant message withstopReason === "error", and bothpiLastAssistantTextOrThrowandpiTurnErrorwrapped the formatted message inNonRetryableTurnError— sorate_limit_errorandauthentication_errortook the same path, and the runs worker (retry: !(err instanceof NonRetryableTurnError)) parked the run after one attempt. Your deployment table says it exactly: everypifailure got 1 attempt; the only run that ever reached 3 was theclaude-harness one, whose harness already treats everything but wall-clock timeout as retryable.Fix: classification by provider error type.
rate_limit_error,overloaded_error,timeout_error,api_error,server_error(plus the less-common siblings:internal_server_error,service_unavailable,temporarily_unavailable,model_service_overloaded) become plainErrors the worker retries;authentication_error/permission_error/invalid_request_errorand non-JSON errors stayNonRetryableTurnError— a second attempt would fail identically. The formatted message is unchanged in both classes (Model provider API error (rate_limit_error): …still greps the same); only the class carries the decision.The retry cadence: retries were immediate
Requeue sets
status='pending'and the worker polls at 50ms — so even with retries,maxAttempts=3hits a rate-limited provider back-to-back, "feeding the thing that caused it" (your section 2 observation, applied to the run path).Fix: an ERROR-driven requeue now carries a not-before —
retryBackoffMs(errorAttempts)= 5s × 2ⁿ capped at 60s — stored in the pending row'slease_expires_atand honored by both claim paths (claimandclaimById, memory + Postgres; nothing else setslease_expires_aton pending rows today, and the reaper only touchesrunningrows, so the overload is backwards-compatible). Lease-expiry requeues — your suspected-crash path — keep their immediate retry and their own poison-pill budget, unchanged.Tests (
pi-retryable-errors.test.ts)NonRetryableTurnError(the exact failure from your runs table), message still matches/rate_limit_error/.retryBackoffMs: 5s → 10s → 20s … capped 60s.claimandclaimById); a fresh run claims immediately.Existing suites updated for the intentional cadence change:
run-store.test.ts/worker-reaper.test.tspassretryBackoffMs: () => 0where they model immediate claim-after-fail (the knob the memory factory now exposes); the lease-expiry reaper tests needed nothing. Full set: 87/87,tsc --noEmitclean.What's deliberately not here
resolveRuntimeChoiceis a pure config read; making it provider-health-aware needs a health signal and a policy ("use B when A fails" needs somewhere to express B). Your note that the refusal-fallback machinery is proven and merely unwired is the right observation — wiring it to availability failures is a design PR of its own.okmean to your uptime monitor).Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.