mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 00:41:39 +00:00
fix(provider-runtime): align transient network error codes across retry paths (#101496)
* fix(provider-runtime): align transient network retries Centralize the shared connection error policy, preserve the bounded provider-only ENOTFOUND retry, and keep gateway DNS/config failures and provider creates fail-fast.\n\nCo-authored-by: Peter Lee <li.xialong@xydigit.com> * test(provider-runtime): cover root retry error codes Exercise direct Node error.code and nested cause.code shapes through the public retry wrapper. Co-authored-by: Peter Lee <li.xialong@xydigit.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Provider network retries:** align provider read/poll/download and agent-wait recovery for transient connection errors, retry bounded provider `ENOTFOUND` failures while leaving gateway `ENOTFOUND` and non-idempotent create operations fail-fast. (#101496) Thanks @xialonglee.
|
||||
- **Session retry classification:** stop permanent provider errors whose identifiers or payload details merely contain 429/5xx digit sequences from re-sending full context, and share bounded rate-limit-window parsing across retry paths. (#105258) Thanks @destire-mio.
|
||||
- **LINE directive templates:** suppress confirms and buttons with blank required fields or unlabeled actions while preserving valid titleless buttons and surrounding reply text. (#105520) Thanks @edenfunf.
|
||||
- **SQLite maintenance schema validation:** reject current-version global and agent databases with missing or drifted canonical tables, constraints, indexes, triggers, or table options before compaction, while accepting supported additive-migration layouts.
|
||||
|
||||
@@ -1069,3 +1069,27 @@ describe("waitForAgentRunsToDrain", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRecoverableAgentWaitError", () => {
|
||||
it.each([
|
||||
"ECONNRESET",
|
||||
"ECONNREFUSED",
|
||||
"ETIMEDOUT",
|
||||
"EPIPE",
|
||||
"EHOSTUNREACH",
|
||||
"ENETUNREACH",
|
||||
"EAI_AGAIN",
|
||||
])("recovers from %s connection failures", (code) => {
|
||||
expect(isRecoverableAgentWaitError(`connect ${code} 127.0.0.1:443`)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"gateway timeout",
|
||||
"ENOENT: no such file",
|
||||
"getaddrinfo ENOTFOUND gateway.example.com",
|
||||
])("does not recover from %s", (error) => {
|
||||
expect(isRecoverableAgentWaitError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { hasRetryableConnectionErrorCode } from "../infra/retryable-network-errors.js";
|
||||
import { normalizeBlockedLivenessWaitStatus } from "../shared/agent-liveness.js";
|
||||
import {
|
||||
isOpenClawInternalSourceReplyMirrorAssistantMessage,
|
||||
@@ -139,7 +140,6 @@ const RECOVERABLE_AGENT_WAIT_ERROR_PATTERNS: readonly RegExp[] = [
|
||||
/gateway not connected/i,
|
||||
/no active .* listener/i,
|
||||
/socket hang up/i,
|
||||
/\b(ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EHOSTUNREACH|ENETUNREACH)\b/i,
|
||||
];
|
||||
|
||||
/** Return true for transient gateway/transport failures that callers may retry. */
|
||||
@@ -151,7 +151,10 @@ export function isRecoverableAgentWaitError(error: string | undefined): boolean
|
||||
if (message.includes("gateway timeout")) {
|
||||
return false;
|
||||
}
|
||||
return RECOVERABLE_AGENT_WAIT_ERROR_PATTERNS.some((pattern) => pattern.test(message));
|
||||
return (
|
||||
hasRetryableConnectionErrorCode(message) ||
|
||||
RECOVERABLE_AGENT_WAIT_ERROR_PATTERNS.some((pattern) => pattern.test(message))
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePendingRunIds(runIds: Iterable<string>): string[] {
|
||||
|
||||
7
src/infra/retryable-network-errors.ts
Normal file
7
src/infra/retryable-network-errors.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Keep connection retry policy aligned across provider reads and gateway waits.
|
||||
const RETRYABLE_CONNECTION_ERROR_CODE_RE =
|
||||
/\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN)\b/i;
|
||||
|
||||
export function hasRetryableConnectionErrorCode(message: string): boolean {
|
||||
return RETRYABLE_CONNECTION_ERROR_CODE_RE.test(message);
|
||||
}
|
||||
@@ -42,4 +42,65 @@ describe("executeProviderOperationWithRetry", () => {
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"ECONNRESET",
|
||||
"ECONNREFUSED",
|
||||
"ETIMEDOUT",
|
||||
"EPIPE",
|
||||
"EHOSTUNREACH",
|
||||
"ENETUNREACH",
|
||||
"EAI_AGAIN",
|
||||
"ENOTFOUND",
|
||||
])("retries %s network failures from structured errors", async (code) => {
|
||||
const cause = Object.assign(new Error("connect failed"), { code });
|
||||
const error =
|
||||
code === "EPIPE"
|
||||
? Object.assign(new Error("socket closed"), { code })
|
||||
: new Error("fetch failed", { cause });
|
||||
const operation = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue("ok");
|
||||
|
||||
await expect(
|
||||
executeProviderOperationWithRetry({
|
||||
provider: "test",
|
||||
stage: "read",
|
||||
operation,
|
||||
retry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
}),
|
||||
).resolves.toBe("ok");
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["HTTP 400", Object.assign(new Error("Bad Request"), { status: 400 })],
|
||||
["ENOENT", new Error("ENOENT: no such file or directory")],
|
||||
])("does not retry %s failures", async (_label, error) => {
|
||||
const operation = vi.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeProviderOperationWithRetry({
|
||||
provider: "test",
|
||||
stage: "read",
|
||||
operation,
|
||||
retry: { attempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry create operations by default", async () => {
|
||||
const operation = vi.fn(async () => {
|
||||
throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeProviderOperationWithRetry({ provider: "test", stage: "create", operation }),
|
||||
).rejects.toThrow("EPIPE");
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Provider operation retry helpers run retryable provider operations with backoff.
|
||||
import { sleepWithAbort } from "../infra/backoff.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { hasRetryableConnectionErrorCode } from "../infra/retryable-network-errors.js";
|
||||
|
||||
export type ProviderOperationRetryStage = "read" | "poll" | "download" | "create";
|
||||
|
||||
@@ -103,13 +104,20 @@ function readErrorCause(error: unknown): unknown {
|
||||
return (error as { cause?: unknown }).cause;
|
||||
}
|
||||
|
||||
// Provider reads get one bounded retry for negative DNS responses. Gateway
|
||||
// waits exclude ENOTFOUND because their configured gateway address needs repair.
|
||||
const PROVIDER_RETRYABLE_DNS_ERROR_CODE_RE = /\bENOTFOUND\b/i;
|
||||
|
||||
function hasProviderRetryableNetworkCode(value: string): boolean {
|
||||
return hasRetryableConnectionErrorCode(value) || PROVIDER_RETRYABLE_DNS_ERROR_CODE_RE.test(value);
|
||||
}
|
||||
|
||||
function hasTransientNetworkSignal(error: unknown, message: string): boolean {
|
||||
const transientCodes = /\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN)\b/i;
|
||||
if (transientCodes.test(message)) {
|
||||
if (hasProviderRetryableNetworkCode(message)) {
|
||||
return true;
|
||||
}
|
||||
const code = readErrorCode(error);
|
||||
if (code && transientCodes.test(code)) {
|
||||
if (code && hasProviderRetryableNetworkCode(code)) {
|
||||
return true;
|
||||
}
|
||||
const cause = readErrorCause(error);
|
||||
@@ -117,11 +125,11 @@ function hasTransientNetworkSignal(error: unknown, message: string): boolean {
|
||||
return false;
|
||||
}
|
||||
const causeCode = readErrorCode(cause);
|
||||
if (causeCode && transientCodes.test(causeCode)) {
|
||||
if (causeCode && hasProviderRetryableNetworkCode(causeCode)) {
|
||||
return true;
|
||||
}
|
||||
const causeMessage = formatErrorMessage(cause);
|
||||
return transientCodes.test(causeMessage);
|
||||
return hasProviderRetryableNetworkCode(causeMessage);
|
||||
}
|
||||
|
||||
function hasTimeoutSignal(error: unknown, message: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user