Skip to content

classifySpawnFailure's fallthrough is its most aggressive schedule — an unrecognised provider outage gets the hardest retry #996

Description

@lilyshen0722

Summary

classifySpawnFailure falls through to RUNTIME, and RUNTIME is the most aggressive schedule in the table. So an error the classifier does not recognise gets the treatment reserved for "transient local fault, probe hard" — the opposite of what an unrecognised provider-side failure deserves.

This is the layer above #995, which is correct and should land as written. #995 adds one word to a vocabulary; this issue is about which way that vocabulary fails when a word is missing.

Measured

Against the module the fleet runs (cli/src/lib/spawn-retry.js, live/main-tracking @ 0cd2bdc9), intervalMs: 5000, jitter 0, * = circuit open:

class=quota          n=1,2,3 ->  900s*  900s*  900s*
class=configuration  n=1,2,3 ->  900s*  900s*  900s*
class=rate_limit     n=1,2,3 ->   60s*  120s*  240s*
class=runtime        n=1,2,3 ->    5s     10s    60s*

RUNTIME is 180× faster than QUOTA on the first retry and is the only class that does not open the circuit at n=1. It is also the value returned by the final return of classifySpawnFailure — i.e. the default for anything unmatched.

It has now happened twice, to two providers

The file records the first instance in a comment above QUOTA_RE:

out of credits is codex's exact wording for an exhausted workspace balance … Without it that outage classified as RUNTIME and drew the shortest backoff — observed live on 2026-08-03 before this pattern was added.

The second was last night: Claude's You've hit your session limit · resets 4am is not in QUOTA_RE (which does contain usage limit), so nine seats probed an account that could not answer until a fixed reset time, on a 5s/10s/60s ladder. #995 adds the string.

Two incidents, two providers, one remedy applied twice. The vocabulary will keep drifting — provider error wording is not an API and changes without notice — so each future reword silently re-selects the most aggressive branch, and the only signal is a fleet-wide stall that someone has to notice by hand.

Suggested fix

Separate "we recognise this as a transient local fault" from "we do not recognise this at all", and give the second the conservative branch:

  • add SPAWN_FAILURE_CLASS.UNKNOWN (or have the fallthrough return QUOTA-equivalent timing), scheduled like CONFIGURATION — long ceiling, circuit open immediately;
  • keep RUNTIME for failures that are positively identified as local and transient (non-zero exit with no recognised provider text, spawn ENOENT of the wrapper itself, etc.);
  • log the class alongside the delay, so an unclassified provider outage is visible as unknown in the seat log instead of presenting as an ordinary runtime blip.

The cost of getting it wrong in the conservative direction is a genuinely transient fault waiting 15 minutes. The cost in the current direction is a fleet hammering a provider through an outage it cannot shorten — and, per #993, burning deliveries that the GC then destroys.

Related pattern

Second classifier found in the same session whose unrecognised verdict resolves toward doing more work:

Both defaults are individually defensible and both bite hardest under precisely the load they exist to damp. Worth a line in docs/development/review-checklist.md: when a classifier feeds a limiter, its unknown verdict must select the conservative branch, and the reviewer should ask what produces unknown under load.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions