Auto-retry on transient provider errors (overloaded, rate limit, 5xx)

- Add retry logic with exponential backoff (2s, 4s, 8s) in AgentSession
- Disable Anthropic SDK built-in retries (maxRetries: 0) to allow app-level handling
- TUI shows retry status with Escape to cancel
- RPC mode: add set_auto_retry, abort_retry commands and auto_retry_start/end events
- Configurable via settings.json: retry.enabled, retry.maxRetries, retry.baseDelayMs
- Exclude context overflow errors from retry (handled by compaction)

fixes #157
This commit is contained in:
Mario Zechner 2025-12-10 23:36:46 +01:00
parent 79f5c6d22e
commit bb445d24f1
11 changed files with 379 additions and 3 deletions

View file

@ -102,6 +102,10 @@ export class InteractiveMode {
private autoCompactionLoader: Loader | null = null;
private autoCompactionEscapeHandler?: () => void;
// Auto-retry state
private retryLoader: Loader | null = null;
private retryEscapeHandler?: () => void;
// Hook UI state
private hookSelector: HookSelectorComponent | null = null;
private hookInput: HookInputComponent | null = null;
@ -806,6 +810,46 @@ export class InteractiveMode {
this.ui.requestRender();
break;
}
case "auto_retry_start": {
// Set up escape to abort retry
this.retryEscapeHandler = this.editor.onEscape;
this.editor.onEscape = () => {
this.session.abortRetry();
};
// Show retry indicator
this.statusContainer.clear();
const delaySeconds = Math.round(event.delayMs / 1000);
this.retryLoader = new Loader(
this.ui,
(spinner) => theme.fg("warning", spinner),
(text) => theme.fg("muted", text),
`Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s... (esc to cancel)`,
);
this.statusContainer.addChild(this.retryLoader);
this.ui.requestRender();
break;
}
case "auto_retry_end": {
// Restore escape handler
if (this.retryEscapeHandler) {
this.editor.onEscape = this.retryEscapeHandler;
this.retryEscapeHandler = undefined;
}
// Stop loader
if (this.retryLoader) {
this.retryLoader.stop();
this.retryLoader = null;
this.statusContainer.clear();
}
// Show error only on final failure (success shows normal response)
if (!event.success) {
this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
}
this.ui.requestRender();
break;
}
}
}

View file

@ -264,6 +264,20 @@ export class RpcClient {
await this.send({ type: "set_auto_compaction", enabled });
}
/**
* Set auto-retry enabled/disabled.
*/
async setAutoRetry(enabled: boolean): Promise<void> {
await this.send({ type: "set_auto_retry", enabled });
}
/**
* Abort in-progress retry.
*/
async abortRetry(): Promise<void> {
await this.send({ type: "abort_retry" });
}
/**
* Execute a bash command.
*/

View file

@ -270,6 +270,20 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return success(id, "set_auto_compaction");
}
// =================================================================
// Retry
// =================================================================
case "set_auto_retry": {
session.setAutoRetryEnabled(command.enabled);
return success(id, "set_auto_retry");
}
case "abort_retry": {
session.abortRetry();
return success(id, "abort_retry");
}
// =================================================================
// Bash
// =================================================================

View file

@ -40,6 +40,10 @@ export type RpcCommand =
| { id?: string; type: "compact"; customInstructions?: string }
| { id?: string; type: "set_auto_compaction"; enabled: boolean }
// Retry
| { id?: string; type: "set_auto_retry"; enabled: boolean }
| { id?: string; type: "abort_retry" }
// Bash
| { id?: string; type: "bash"; command: string }
| { id?: string; type: "abort_bash" }
@ -127,6 +131,10 @@ export type RpcResponse =
| { id?: string; type: "response"; command: "compact"; success: true; data: CompactionResult }
| { id?: string; type: "response"; command: "set_auto_compaction"; success: true }
// Retry
| { id?: string; type: "response"; command: "set_auto_retry"; success: true }
| { id?: string; type: "response"; command: "abort_retry"; success: true }
// Bash
| { id?: string; type: "response"; command: "bash"; success: true; data: BashResult }
| { id?: string; type: "response"; command: "abort_bash"; success: true }