Skip to content

feat(runs): retry transient provider errors with backoff - #624

Open
yzxcj797 wants to merge 1 commit into
yc-software:mainfrom
yzxcj797:fix/pi-retryable-errors-602
Open

feat(runs): retry transient provider errors with backoff#624
yzxcj797 wants to merge 1 commit into
yc-software:mainfrom
yzxcj797:fix/pi-retryable-errors-602

Conversation

@yzxcj797

@yzxcj797 yzxcj797 commented Aug 20, 2026

Copy link
Copy Markdown

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

piAssistantError fires on any assistant message with stopReason === "error", and both piLastAssistantTextOrThrow and piTurnError wrapped the formatted message in NonRetryableTurnError — so rate_limit_error and authentication_error took the same path, and the runs worker (retry: !(err instanceof NonRetryableTurnError)) parked the run after one attempt. Your deployment table says it exactly: every pi failure got 1 attempt; the only run that ever reached 3 was the claude-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 plain Errors the worker retries; authentication_error / permission_error / invalid_request_error and non-JSON errors stay NonRetryableTurnError — 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=3 hits 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's lease_expires_at and honored by both claim paths (claim and claimById, memory + Postgres; nothing else sets lease_expires_at on pending rows today, and the reaper only touches running rows, 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)

  • rate_limit_error → not NonRetryableTurnError (the exact failure from your runs table), message still matches /rate_limit_error/.
  • overload / api_error / server_error / timeout → retryable; authentication / permission / invalid_request → non-retryable; plain-text errors keep the historical class.
  • retryBackoffMs: 5s → 10s → 20s … capped 60s.
  • Memory-store behavior: an error-requeued run is not claimable inside the window (both claim and claimById); a fresh run claims immediately.

Existing suites updated for the intentional cadence change: run-store.test.ts / worker-reaper.test.ts pass retryBackoffMs: () => 0 where 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 --noEmit clean.

What's deliberately not here

  • Failover (section 3): resolveRuntimeChoice is 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.
  • Cron tick backoff (section 2's batch shape): one failing cron aborting its tick's batch deserves its own fix (isolation + per-cron backoff), separate from the run-level backoff landed here.
  • /healthz (section 4): surfacing model reachability in a health endpoint is an ops contract decision (what does ok mean to your uptime monitor).

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant