Skip to content

fix(failover): a walled last cascade step surfaces a raw provider 403 — Claude Code shows it as an auth failure and prompts for login #409

Description

@jsboige

Symptom (measured 2026-10-08, ai-01)

The hub restarted undrained at ~08:50:56Z (it was already unreachable from ~08:48:35Z; the cause is being chased on the dashboard). During that time the ai-01 sidecar went AUTONOMOUS twice: 08:48:55→08:49:55Z and 08:50:53→08:51:55Z. Its local sonnet cascade (qwen-token-plan@deepseek-v4.1-flash>kc@k3, no PAYG tail) was walled at every step:

  • GLM nominal → 429
  • Qwen → 429
  • Kimi (kc@k3, last step) → 403 You've reached your weekly (7-day) usage limit…

The last step's 403 reached Claude Code unchanged. Claude Code renders any 403 as an auth failure:

Failed to authenticate. API Error: 403 You've reached your weekly (7-day) usage limit. …

It then offered an Anthropic login prompt. An agent session on ai-01 stayed blocked on that prompt until the user intervened. The user first suspected a Claude Code version problem; it was not one.

Where

proxy-server.ts handleWithCascade: if (rule && stepIndex === rule.steps.length - 1) return response; // last step also walled returns the upstream response unchanged. It keeps the upstream's status and body, which here means a provider-specific 403.

Proposal

When the cascade surfaces a refusal that isQuotaExhaustion classified as a wall (the step has just been marked failed for it), rewrite it to the status the client understands for "out of budget". That is what Anthropic's own API does for its weekly cap:

  • status 429, {"type":"error","error":{"type":"rate_limit_error","message":"[Failover] <role>: every step walled — last: <provider> <status>: <first 200 chars of upstream message>"}};
  • a retry-after derived from the earliest known reset among the walled steps (body-parsed or _RESET), omitted when unknown.

Scope and invariants:

Mitigation outside this issue

Add a PAYG tail to the ai-01 sidecar sonnet cascade, so an AUTONOMOUS window cannot end on a subscription wall. That needs a drained recreate of the sidecar and is tracked on the dashboard (gate 5b).

Acceptance

  • Route test: the last step answers 403 with a weekly-limit body, and the client gets 429 rate_limit_error with the labeled message. Same with 402.
  • Negative controls: 401 and 404 at the last step surface unchanged; the native-lane 429 is unchanged.
  • Mutation: removing the rewrite makes the route test go red.

Activity

  1. added 2 commits that reference this issue on Oct 9, 2026
  2. jsboige commented on Oct 9, 2026

    @jsboige
    OwnerAuthor

    Correctif mergé : PR #427 (4a0d8408). Une dernière étape murée sort en 429 rate_limit_error, avec un retry-after borné par la prochaine re-sonde et plafonné à 40 min ; la lane native est exclue. DoD restante : un [Failover] WALL-SURFACE observé dans le log du hub (le collecteur #347) après le recreate qui embarque 4a0d8408. Je ferme sur cette ligne. — myia-ai-01

  3. jsboige commented on Oct 10, 2026

    @jsboige
    OwnerAuthor

    Veille post-rebuild (dispatch 11:28Z, grain 6) — l'observable walled coule ; et une correction de ma part.

    Correction d'abord : mon annonce de rebuild du 10/10 09:15Z décrivait #427 comme « marqueurs [Failover] WALL-SURFACE au collecteur ». Ce marqueur n'existe pas — grep WALL-SURFACE rend 0 dans packages/cli/src comme dans failover-events.log. #427 est « a walled last cascade step surfaces as 429 rate_limit_error, bounded retry-after » : c'est la réécriture du refus de dernière marche murée vers le vocabulaire budget du client, pas un marqueur de log. L'annonce décrivait la mauvaise PR sous le mauvais nom — désolé pour la confusion semée.

    La veille elle-même (l'instrument qui existe) : failover-events.log porte 386 marques walled au total, dont le flux est vivant depuis le recreate (instanceId c2a06b8f — post-rebuild), exemples :

    marque mur
    step sonnet[0] (Mistral GLM) walled — count=4 ttl=240min (HTTP 402 from glm-5.3) 402 DeepSeek-class (solde)
    step sonnet[1] (Kimi K3) walled — count=3 ttl=60min (HTTP 403 from claude-sonnet-5-5) 403 hebdo Kimi — la forme exacte du symptom #409
    step haiku[1] (Sonnet role) walled — count=3 ttl=60min (HTTP 429 from claude-haiku-4-5-20251001) 429 quota

    La moitié classification de #409/#427 est donc mesurée en prod depuis le rebuild (les 403 Kimi de dernière marche sont classés walled + TTL, plus jamais traités comme erreurs de câblage côté proxy). La moitié surfacing (le client voit 429 rate_limit_error au lieu du 403 brut) ne se lit pas dans ce log — elle est visible côté client à la prochaine surface de dernière marche ; je n'ai pas de capture d'un tel événement post-rebuild à ce stade, et je ne le prétends pas.

    Instrument noté au passage : failover-events.log survit aux recreates (§32), docker logs non.

    Refs #409 · #427 · #347

    🤖 Generated with Claude Code

  4. jsboige commented on Oct 11, 2026

    @jsboige
    OwnerAuthor

    myia-ai-01 (coordinateur) : fermeture vérifiée. Livré par #427 (4a0d8408, mergée le 09/10). Cette PR portait seulement « Refs », d'où l'orpheline. Les AC sont des AC de code, et je les ai vérifiés sur main 8e4bbc3b : proxy-server-wall-surface-route.test.ts → 6/6, avec R1 (Kimi 403 → 429), R2 (DeepSeek 402 → 429) et les contrôles négatifs N1 (401 inchangé), N2 (404 inchangé) et N3 (lane native exclue). Mutation : la garde proxy-server.ts:1758 neutralisée donne 3 fail (R1, R1b, R2), et les contrôles négatifs restent verts. Fichier restauré, cmp identique. L'observable de prod (un 429 réécrit sur un mur réel) n'est pas un AC : po-2025 le garde en veille, et un négatif ne contredit pas le code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions