Skip to content

Commit c65abce

Browse files
authored
Merge pull request #744 from code-yeongyu/fix/429-no-fallback-in-turn-retry
fix(session): degrade fallback-unavailable 429s to in-turn retry
2 parents 7e4f647 + b61b33a commit c65abce

8 files changed

Lines changed: 319 additions & 48 deletions

File tree

.agents/skills/senpi-qa/scripts/lib/mock-loop-hint-429.mjs

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
* immediate fallback, bounded probe-back, probe ok
1616
* clears the cooldown and the next turn restores the
1717
* primary.
18+
* no-hint-429-no-chain No hint AND no usable fallback chain: the turn
19+
* degrades to bounded same-model in-turn retries on
20+
* the exponential schedule instead of dying with
21+
* "Retry failed after 0 attempts".
1822
*
1923
* Every wait is bounded by a scripted hint measured in seconds, and every
2024
* assertion waits on an EVENT (never a fixed sleep), so runs are deterministic.
@@ -36,8 +40,14 @@ const FALLBACK_MARKER = "SENPI-QA-HINT429-FALLBACK-6c1b";
3640
const IN_TURN_HINT_SECONDS = 8;
3741
const PROBE_BACK_HINT_MS = 4000;
3842
const PROBE_BACK_CAP_MS = 1000;
43+
const NO_CHAIN_BASE_DELAY_MS = 50;
3944

40-
export const HINT_429_SCENARIOS = ["hinted-429-in-turn", "no-hint-429-fast-fallback", "hinted-429-probe-back"];
45+
export const HINT_429_SCENARIOS = [
46+
"hinted-429-in-turn",
47+
"no-hint-429-fast-fallback",
48+
"hinted-429-probe-back",
49+
"no-hint-429-no-chain",
50+
];
4151

4252
export function isHint429Scenario(name) {
4353
return HINT_429_SCENARIOS.includes(name);
@@ -251,15 +261,30 @@ export async function runHint429Scenario({ scenarioName, apiName, evidenceSlug }
251261
? { primaryLimitedRequests: 2, rateLimitHeaders: { "retry-after": String(IN_TURN_HINT_SECONDS) }, rateLimitMessage: "primary rate limited" }
252262
: scenarioName === "no-hint-429-fast-fallback"
253263
? { primaryLimitedRequests: 4, rateLimitMessage: "All tokens rate limited" }
254-
: { primaryLimitedRequests: 1, rateLimitHeaders: { "retry-after-ms": String(PROBE_BACK_HINT_MS) }, rateLimitMessage: "primary rate limited" };
264+
: scenarioName === "no-hint-429-no-chain"
265+
? { primaryLimitedRequests: 2, rateLimitMessage: "All tokens rate limited" }
266+
: { primaryLimitedRequests: 1, rateLimitHeaders: { "retry-after-ms": String(PROBE_BACK_HINT_MS) }, rateLimitMessage: "primary rate limited" };
255267
const server = await startHint429Server({ ...script, primaryMarker: PRIMARY_MARKER, fallbackMarker: FALLBACK_MARKER });
256-
writeMockModelsJson(box.agentDir, server, API_NAME, {}, {
257-
models: [{ id: HINT_429_FALLBACK_MODEL_ID }],
258-
retry:
259-
scenarioName === "hinted-429-probe-back"
260-
? retrySettings({ hintedWaitCapMs: PROBE_BACK_CAP_MS, probeBackMaxMs: 3_600_000 })
261-
: retrySettings(),
262-
});
268+
// The no-chain scenario registers NO fallback model and NO chain, so the
269+
// shipped default chains cannot resolve for the mock primary either.
270+
const mockExtras =
271+
scenarioName === "no-hint-429-no-chain"
272+
? {
273+
retry: {
274+
enabled: true,
275+
maxRetries: 3,
276+
baseDelayMs: NO_CHAIN_BASE_DELAY_MS,
277+
provider: { maxRetries: 0, maxRetryDelayMs: 60000 },
278+
},
279+
}
280+
: {
281+
models: [{ id: HINT_429_FALLBACK_MODEL_ID }],
282+
retry:
283+
scenarioName === "hinted-429-probe-back"
284+
? retrySettings({ hintedWaitCapMs: PROBE_BACK_CAP_MS, probeBackMaxMs: 3_600_000 })
285+
: retrySettings(),
286+
};
287+
writeMockModelsJson(box.agentDir, server, API_NAME, {}, mockExtras);
263288
const client = new HintRpcClient({
264289
env: hermeticEnv(box.env),
265290
cwd: box.cwd,
@@ -270,6 +295,7 @@ export async function runHint429Scenario({ scenarioName, apiName, evidenceSlug }
270295
await client.send({ type: "get_state" }); // ensure the session booted
271296
if (scenarioName === "hinted-429-in-turn") await assertInTurn(checks, client, server, texts);
272297
else if (scenarioName === "no-hint-429-fast-fallback") await assertFastFallback(checks, client, server, texts);
298+
else if (scenarioName === "no-hint-429-no-chain") await assertNoChainDegrade(checks, client, server, texts);
273299
else await assertProbeBack(checks, client, server, texts);
274300
process.stdout.write(`SENPI_QA_HINT429_TRANSCRIPT ${transcript(scenarioName, server, client)}\n`);
275301
checkRealAuthUnchanged(checks, guard);
@@ -357,6 +383,33 @@ async function assertFastFallback(checks, client, server, texts) {
357383
);
358384
}
359385

386+
/** No hint AND no usable chain: bounded same-model in-turn retries instead of a dead turn. */
387+
async function assertNoChainDegrade(checks, client, server, texts) {
388+
texts.push(await runOneTurn(client, `Return ${PRIMARY_MARKER} once the scripted rate limit clears.`));
389+
const models = server.requests.map((request) => request.model);
390+
checks.ok(
391+
"no-hint-429-no-chain: all three attempts stay on the primary model",
392+
models.length === 3 && models.every((model) => model === HINT_429_PRIMARY_MODEL_ID),
393+
`sequence=${models.join(" -> ") || "none"}`,
394+
);
395+
checks.ok(
396+
"no-hint-429-no-chain: zero fallback switches",
397+
client.events.filter((event) => event.type === "retry_fallback_applied").length === 0,
398+
`retry_fallback_applied=${client.events.filter((event) => event.type === "retry_fallback_applied").length}`,
399+
);
400+
const delays = client.events.filter((event) => event.type === "auto_retry_start").map((event) => event.delayMs);
401+
checks.ok(
402+
"no-hint-429-no-chain: two exponential in-turn waits are scheduled",
403+
delays.length === 2 && delays[0] === NO_CHAIN_BASE_DELAY_MS && delays[1] === NO_CHAIN_BASE_DELAY_MS * 2,
404+
`delayMs=${delays.join(",") || "none"} expected=${NO_CHAIN_BASE_DELAY_MS},${NO_CHAIN_BASE_DELAY_MS * 2}`,
405+
);
406+
checks.ok(
407+
"no-hint-429-no-chain: the degraded retry recovers on the primary model",
408+
texts[0].includes(PRIMARY_MARKER),
409+
`text=${texts[0].slice(0, 80)}`,
410+
);
411+
}
412+
360413
/** Tier 2: hint above the shrunk cap -> immediate fallback + bounded probe-back that restores the primary. */
361414
async function assertProbeBack(checks, client, server, texts) {
362415
texts.push(await runOneTurn(client, `Return ${FALLBACK_MARKER} from whichever model can serve this turn.`));

.agents/skills/senpi-qa/scripts/mock-loop.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
* startup flag and would try to load the path as a dotenv file before the script runs)
2727
* node mock-loop.mjs --with-mcp-tool mcp_fx_tool_1 --tool-args '{"value":"ok"}'
2828
* node mock-loop.mjs --scenario transient-recover|budget-exhaust|server-error-fallback|long-retry-after|billing-swap|anthropic-policy-refusal-fallback|kimi-xtml-thinking-recover
29-
* node mock-loop.mjs --scenario hinted-429-in-turn|no-hint-429-fast-fallback|hinted-429-probe-back
29+
* node mock-loop.mjs --scenario hinted-429-in-turn|no-hint-429-fast-fallback|hinted-429-probe-back|no-hint-429-no-chain
3030
* node mock-loop.mjs --run "prompt" [--api ...] [--evidence SLUG]
3131
*/
3232

@@ -628,7 +628,7 @@ if (argv[0] === "--self-test") {
628628
" node mock-loop.mjs --with-truncated-text-tool-leak --api <anthropic-messages|openai-completions>",
629629
" node mock-loop.mjs --with-mcp-tool <tool> [--tool-args JSON]",
630630
" node mock-loop.mjs --scenario <transient-recover|budget-exhaust|server-error-fallback|long-retry-after|billing-swap|anthropic-policy-refusal-fallback|kimi-xtml-thinking-recover|ttsr-collapse|ttsr-leak|ttsr-repetitive-turns> [--api <name>]",
631-
" node mock-loop.mjs --scenario <hinted-429-in-turn|no-hint-429-fast-fallback|hinted-429-probe-back>",
631+
" node mock-loop.mjs --scenario <hinted-429-in-turn|no-hint-429-fast-fallback|hinted-429-probe-back|no-hint-429-no-chain>",
632632
" node mock-loop.mjs --run <prompt> [--api <name>]",
633633
` APIs: ${ALL_APIS.join(", ")}`,
634634
"",

packages/coding-agent/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515

1616
### Fixed
1717

18+
- Fixed a 429/rate-limit failure without a retry-after hint killing the turn with "Retry failed after 0 attempts"
19+
when no fallback chain was usable for the active model. Such failures now degrade to same-model in-turn retries on
20+
the ordinary exponential schedule, hinted waits below the probe-back ceiling retry in-turn clamped to
21+
`retry.hintedWaitCapMs`, hour-plus hinted waits name the provider-requested wait in the final error, and retry
22+
exhaustion reports the true attempt count ([#744](https://github.com/code-yeongyu/senpi/pull/744)).
23+
1824
### Removed
1925

2026
## [2026.8.6] - 2026-08-06

packages/coding-agent/src/core/agent-session.ts

Lines changed: 60 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ import { RetryFallbackController } from "./retry-fallback/controller.ts";
137137
import { SelectorCooldowns } from "./retry-fallback/cooldown.ts";
138138
import {
139139
classifyRateLimitedWait,
140+
degradeWithoutFallback,
141+
type HintTier,
140142
nextInTurnDelayMs,
141143
type ProbePhase,
142144
probeBackSchedule,
@@ -5758,6 +5760,56 @@ export class AgentSession {
57585760
};
57595761
}
57605762

5763+
/**
5764+
* A 429-class failure with no usable fallback candidate must not fail the
5765+
* turn with zero attempts: a provider answering 429 is asking for a retry.
5766+
* No-hint and tier2 waits degrade to same-model in-turn retries under the
5767+
* normal retry budget (tier2 clamps the hinted wait to the in-turn cap);
5768+
* only tier3 hour-plus waits stay terminal, with the requested wait named
5769+
* in the final error. Returns the in-turn retry delay, or undefined after
5770+
* emitting the terminal auto_retry_end.
5771+
*/
5772+
private _degradeRateLimitedWithoutFallback(
5773+
tier: HintTier,
5774+
hintMs: number | undefined,
5775+
message: AssistantMessage,
5776+
errorMessage: string,
5777+
): number | undefined {
5778+
const settings = this.settingsManager.getRetrySettings();
5779+
const hintSettings = this.settingsManager.getHintPolicySettings();
5780+
const finishTurn = (attempt: number, finalError: string | undefined) => {
5781+
const exhaustedChainKey = this._retryFallback.exhaustedChainKey;
5782+
if (exhaustedChainKey) {
5783+
this._emit({ type: "retry_fallback_exhausted", chainKey: exhaustedChainKey, lastError: errorMessage });
5784+
}
5785+
this._emit({ type: "auto_retry_end", success: false, attempt, finalError });
5786+
this._retryAttempt = 0;
5787+
this._resetHintTierState();
5788+
this._resolveRetry();
5789+
};
5790+
const degraded = degradeWithoutFallback(
5791+
tier,
5792+
hintMs,
5793+
this._retryAttempt + 1,
5794+
settings.baseDelayMs,
5795+
hintSettings.hintedWaitCapMs,
5796+
);
5797+
if (degraded.kind === "fail") {
5798+
const waitSeconds = Math.ceil(degraded.hintMs / 1000);
5799+
finishTurn(
5800+
this._retryAttempt,
5801+
`Provider requested a ${waitSeconds}s wait before retrying and no usable fallback model is available. ${message.errorMessage ?? ""}`,
5802+
);
5803+
return undefined;
5804+
}
5805+
this._retryAttempt++;
5806+
if (this._retryAttempt > settings.maxRetries) {
5807+
finishTurn(this._retryAttempt - 1, message.errorMessage);
5808+
return undefined;
5809+
}
5810+
return degraded.delayMs;
5811+
}
5812+
57615813
/**
57625814
* Handle retryable errors with exponential backoff.
57635815
* @returns whether retry continuation started, was blocked by compaction, or was not handled
@@ -5861,29 +5913,15 @@ export class AgentSession {
58615913
const tier = classifyRateLimitedWait(hintMs, hintSettings);
58625914
is429TierRouted = true;
58635915
if (tier === "no-hint-fast-fallback") {
5864-
// Skip same-model retries entirely; fall back immediately.
5916+
// Fall back immediately when a candidate exists; otherwise degrade
5917+
// to same-model in-turn retries instead of failing the turn.
58655918
switchedFallback = await this._retryFallback.tryFallback("transient", { errorMessage });
58665919
if (switchedFallback) {
58675920
this._retryAttempt = 1;
58685921
} else {
5869-
const exhaustedChainKey = this._retryFallback.exhaustedChainKey;
5870-
if (exhaustedChainKey) {
5871-
this._emit({
5872-
type: "retry_fallback_exhausted",
5873-
chainKey: exhaustedChainKey,
5874-
lastError: errorMessage,
5875-
});
5876-
}
5877-
this._emit({
5878-
type: "auto_retry_end",
5879-
success: false,
5880-
attempt: 0,
5881-
finalError: message.errorMessage,
5882-
});
5883-
this._retryAttempt = 0;
5884-
this._resetHintTierState();
5885-
this._resolveRetry();
5886-
return "not-handled";
5922+
const degradedDelayMs = this._degradeRateLimitedWithoutFallback(tier, hintMs, message, errorMessage);
5923+
if (degradedDelayMs === undefined) return "not-handled";
5924+
hintTierDelayMs = degradedDelayMs;
58875925
}
58885926
} else if (tier === "tier1-in-turn") {
58895927
this._retryAttempt++;
@@ -5978,24 +6016,9 @@ export class AgentSession {
59786016
this._armProbeBackForDemotedSelector(selector, remainingHintMs);
59796017
}
59806018
} else {
5981-
const exhaustedChainKey = this._retryFallback.exhaustedChainKey;
5982-
if (exhaustedChainKey) {
5983-
this._emit({
5984-
type: "retry_fallback_exhausted",
5985-
chainKey: exhaustedChainKey,
5986-
lastError: errorMessage,
5987-
});
5988-
}
5989-
this._emit({
5990-
type: "auto_retry_end",
5991-
success: false,
5992-
attempt: 0,
5993-
finalError: message.errorMessage,
5994-
});
5995-
this._retryAttempt = 0;
5996-
this._resetHintTierState();
5997-
this._resolveRetry();
5998-
return "not-handled";
6019+
const degradedDelayMs = this._degradeRateLimitedWithoutFallback(tier, hintMs, message, errorMessage);
6020+
if (degradedDelayMs === undefined) return "not-handled";
6021+
hintTierDelayMs = degradedDelayMs;
59996022
}
60006023
}
60016024
}

packages/coding-agent/src/core/changes.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
# changes
22

3+
## Degrade fallback-unavailable 429s to in-turn retry (2026-08-06)
4+
5+
### What changed
6+
7+
- A 429-class failure whose hint tier routes to fallback (`no-hint-fast-fallback`, tier2, tier3) no
8+
longer fails the turn with `auto_retry_end { attempt: 0 }` when no fallback candidate is usable
9+
(no chain for the model, chain exhausted, candidates cooling, or unauthenticated).
10+
- No-hint failures degrade to same-model in-turn retries on the ordinary `settings.retry`
11+
exponential schedule; tier2 hinted waits retry in-turn with the wait clamped to
12+
`hintedWaitCapMs`; tier3 (>= `probeBackMaxMs`) waits stay terminal but the final error now names
13+
the provider-requested wait in seconds.
14+
- The pure policy is `degradeWithoutFallback` in `retry-fallback/hint-policy.ts`;
15+
`agent-session.ts` routes both former instant-death branches through
16+
`_degradeRateLimitedWithoutFallback`, which also reports the TRUE attempt count on budget
17+
exhaustion.
18+
19+
### Why
20+
21+
- Providers that send hint-less 429s (e.g. wafer `server_overloaded` bodies that literally say
22+
"Please retry shortly") killed the turn on the FIRST 429 for any model without a usable fallback
23+
chain, surfacing "Retry failed after 0 attempts". sst/opencode retries such failures in-turn
24+
with a visible countdown and openai/codex replays the turn within its stream budget; failing
25+
with zero attempts was strictly worse than both.
26+
27+
### Why this cannot be expressed externally
28+
29+
- Retry admission, the retry promise, `_retryAttempt` accounting, and the hint tier router live in
30+
`AgentSession._handleRetryableError`; an extension cannot re-enter the continuation path after
31+
the fallback controller declines a candidate.
32+
- Expected merge-conflict zone: `agent-session.ts` `_handleRetryableError` 429 tier routing and the
33+
`retry-fallback/hint-policy.ts` tail.
34+
335
## Absolute-cap compaction rejection message (2026-08-05)
436

537
- `describeCompactionRejection()` for `"per-turn-cap"` now reads "absolute compaction cap reached for

packages/coding-agent/src/core/retry-fallback/hint-policy.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,25 @@ export function nextInTurnDelayMs(
8181
demoteToProbeBack: false,
8282
};
8383
}
84+
85+
export type DegradedRateLimitAction = { kind: "in-turn"; delayMs: number } | { kind: "fail"; hintMs: number };
86+
87+
/**
88+
* Policy for a 429-class failure when no fallback candidate is usable: no-hint
89+
* (and tier1) failures retry in-turn on the exponential schedule, tier2 hints
90+
* retry in-turn with the wait clamped to the in-turn cap, and only tier3
91+
* (probe-back-max or longer) hinted waits stay terminal.
92+
*/
93+
export function degradeWithoutFallback(
94+
tier: HintTier,
95+
hintMs: number | undefined,
96+
attempt: number,
97+
baseDelayMs: number,
98+
hintedWaitCapMs: number,
99+
): DegradedRateLimitAction {
100+
if (tier === "tier3-fallback-only") return { kind: "fail", hintMs: hintMs ?? 0 };
101+
if (tier === "tier2-fallback-probe-back") {
102+
return { kind: "in-turn", delayMs: Math.min(hintMs ?? hintedWaitCapMs, hintedWaitCapMs) };
103+
}
104+
return { kind: "in-turn", delayMs: baseDelayMs * 2 ** (attempt - 1) };
105+
}

packages/coding-agent/test/suite/retry-fallback-hint-policy.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
classifyRateLimitedWait,
4+
degradeWithoutFallback,
45
nextInTurnDelayMs,
56
probeBackSchedule,
67
} from "../../src/core/retry-fallback/hint-policy.ts";
@@ -434,3 +435,34 @@ describe("nextInTurnDelayMs", () => {
434435
expect(result.demoteToProbeBack).toBe(true);
435436
});
436437
});
438+
439+
// ---------------------------------------------------------------------------
440+
// degradeWithoutFallback
441+
// ---------------------------------------------------------------------------
442+
443+
describe("degradeWithoutFallback", () => {
444+
it("returns exponential in-turn delays for a no-hint 429", () => {
445+
expect(degradeWithoutFallback("no-hint-fast-fallback", undefined, 1, BASE, CAP)).toEqual({
446+
kind: "in-turn",
447+
delayMs: BASE,
448+
});
449+
expect(degradeWithoutFallback("no-hint-fast-fallback", undefined, 3, BASE, CAP)).toEqual({
450+
kind: "in-turn",
451+
delayMs: BASE * 4,
452+
});
453+
});
454+
455+
it("clamps tier2 hinted waits to the in-turn cap", () => {
456+
expect(degradeWithoutFallback("tier2-fallback-probe-back", CAP + 60_000, 1, BASE, CAP)).toEqual({
457+
kind: "in-turn",
458+
delayMs: CAP,
459+
});
460+
});
461+
462+
it("stays terminal for tier3 waits and reports the hint", () => {
463+
expect(degradeWithoutFallback("tier3-fallback-only", PROBE_MAX, 1, BASE, CAP)).toEqual({
464+
kind: "fail",
465+
hintMs: PROBE_MAX,
466+
});
467+
});
468+
});

0 commit comments

Comments
 (0)