fix(coding-agent): retry explicit provider retry errors

closes #6019
This commit is contained in:
Armin Ronacher
2026-06-24 18:12:25 +02:00
parent c29bbc0958
commit 371adcf371
7 changed files with 178 additions and 18 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed retry classification for provider errors that explicitly tell callers to retry the request ([#6019](https://github.com/earendil-works/pi/issues/6019)).
## [0.80.2] - 2026-06-23
### Changed
+1
View File
@@ -43,5 +43,6 @@ export type {
OAuthSelectPrompt,
} from "./utils/oauth/types.ts";
export * from "./utils/overflow.ts";
export * from "./utils/retry.ts";
export * from "./utils/typebox-helpers.ts";
export * from "./utils/validation.ts";
+96
View File
@@ -0,0 +1,96 @@
import type { AssistantMessage } from "../types.ts";
function buildProviderErrorPattern(patterns: readonly string[]): RegExp {
return new RegExp(patterns.join("|"), "i");
}
const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([
// OpenCode Go/free-tier limits returned as 429 JSON error types by OpenCode's
// Zen API. These are subscription/account limits, not transient throttles.
"GoUsageLimitError",
"FreeUsageLimitError",
// OpenCode Go subscription-limit text asks users to enable available-balance
// usage after rolling/weekly/monthly limits are reached.
"Monthly usage limit reached",
"available balance",
// Generic quota/budget/billing exhaustion. `insufficient_quota` is OpenAI's
// quota/billing error code; the other strings cover common gateway wording.
"insufficient_quota",
"out of budget",
"quota exceeded",
"billing",
]);
const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
// Generic provider load, HTTP status, and server-side transient failures.
"overloaded",
"rate.?limit",
"too many requests",
"429",
"500",
"502",
"503",
"504",
"service.?unavailable",
"server.?error",
"internal.?error",
// Wrapper/provider text for transient upstream failures, including OpenRouter
// "Provider returned error" responses (#2264).
"provider.?returned.?error",
// Network, proxy, and fetch transport failures. This includes OpenAI Codex
// raw-fetch failures such as "upstream connect", "connection refused", and
// "reset before headers" (#733), plus OpenRouter connection drops (#3317).
"network.?error",
"connection.?error",
"connection.?refused",
"connection.?lost",
"other side closed",
"fetch failed",
"upstream.?connect",
"reset before headers",
"socket hang up",
"timed? out",
"timeout",
"terminated",
// WebSocket transports can report close/error text instead of HTTP/fetch text.
"websocket.?closed",
"websocket.?error",
// Premature stream endings from SDKs and transports. Anthropic can throw
// "stream ended without ..." and "Anthropic stream ended before message_stop"
// (#4433); Bedrock/Smithy can throw an HTTP/2 no-response error (#3594).
"ended without",
"stream ended before message_stop",
"http2 request did not get a response",
// Provider-requested retry delay cap failures should flow through the outer
// retry policy so callers can surface/abort the backoff (#1123).
"retry delay",
// Explicit retry guidance emitted mid-stream by OpenAI Responses and Bedrock
// stream exceptions (#6019).
"you can retry your request",
"try your request again",
"please retry your request",
]);
/**
* Classifies whether a failed assistant message looks like a transient provider
* or transport error, so callers can decide if the last assistant turn should be
* restarted.
*
* This does not implement retry policy. Callers should first handle context
* overflow separately, then apply their own retry budget, backoff, and reporting
* before restarting the assistant turn.
*/
export function isRetryableAssistantError(message: AssistantMessage): boolean {
if (message.stopReason !== "error" || !message.errorMessage) return false;
const errorMessage = message.errorMessage;
if (NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN.test(errorMessage)) return false;
return RETRYABLE_PROVIDER_ERROR_PATTERN.test(errorMessage);
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { fauxAssistantMessage } from "../src/providers/faux.ts";
import { isRetryableAssistantError } from "../src/utils/retry.ts";
const openAIExplicitRetryMessage =
"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_******** in your message.";
const bedrockExplicitRetryMessage =
'{"message":"The system encountered an unexpected error during processing. Try your request again."}';
describe("provider retry classification", () => {
it("matches explicit provider retry guidance", () => {
expect(
isRetryableAssistantError(
fauxAssistantMessage("", { stopReason: "error", errorMessage: openAIExplicitRetryMessage }),
),
).toBe(true);
expect(
isRetryableAssistantError(
fauxAssistantMessage("", { stopReason: "error", errorMessage: bedrockExplicitRetryMessage }),
),
).toBe(true);
});
it("keeps provider limit errors non-retryable", () => {
expect(
isRetryableAssistantError(
fauxAssistantMessage("", { stopReason: "error", errorMessage: "429 quota exceeded" }),
),
).toBe(false);
});
it("classifies assistant error messages", () => {
expect(
isRetryableAssistantError(fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" })),
).toBe(true);
expect(isRetryableAssistantError(fauxAssistantMessage("not an error"))).toBe(false);
});
});