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:
Peter Lee
2026-07-12 18:33:24 -05:00
committed by GitHub
parent b1eb59c0be
commit fee89ff4cb
6 changed files with 111 additions and 7 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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[] {

View 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);
}

View File

@@ -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);
});
});

View File

@@ -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 {