mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 21:04:45 +00:00
Fix gateway handling for undici HTTP2 session teardown (#81838)
* fix: handle undici HTTP2 session teardown * docs: add gateway HTTP2 changelog entry
This commit is contained in:
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI: lazy-load model, plugin, and device runtime helpers and keep channel option help on generated startup metadata or generic fallback text so parent/help output renders without importing those runtime paths.
|
||||
- CLI: route `plugins list --json` through the parsed command fast path and cover it in response budgets so plugin JSON inventory avoids full CLI registration work.
|
||||
- Gateway/session history: carry monotonic transcript message sequence through live updates and refresh SSE history when stale sequence input would otherwise append bad incremental state. (#81474) Thanks @samzong.
|
||||
- Gateway/network: keep OpenClaw-installed undici dispatchers on HTTP/1.1 and treat destroyed HTTP/2 session errors as recoverable network teardown, preventing `ERR_HTTP2_INVALID_SESSION` from crashing active gateway turns. Fixes #81627. (#81838) Thanks @joshavant.
|
||||
- Memory/daily-files: widen the daily-memory file matcher used by Dreaming, rem-backfill, rem-harness, the doctor sweep, and short-term promotion so `memory/YYYY-MM-DD-<slug>.md` files written by the bundled session-memory hook (and any future slugged variants) are discovered alongside the date-only `memory/YYYY-MM-DD.md` shape. Date extraction still uses the leading `YYYY-MM-DD` capture group, so per-day ingestion/promotion semantics are unchanged for existing date-only files; slugged files now flow through the same paths instead of being silently skipped. Fixes #69536. Thanks @jack-stormentswe.
|
||||
- macOS/Gateway: fail managed LaunchAgent stop and restart when the configured gateway port remains busy after cleanup instead of reporting success while a listener survives. Fixes #73132. Thanks @BunsDev.
|
||||
- Telegram: reuse the sticky IPv4 Bot API transport for periodic getMe health checks, so IPv4-working hosts with broken IPv6 egress stop logging repeated probe timeouts. Fixes #76852. (#76856) Thanks @SymbolStar.
|
||||
|
||||
@@ -164,6 +164,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
|
||||
expect(next.options).toEqual({
|
||||
bodyTimeout: 1_900_000,
|
||||
headersTimeout: 1_900_000,
|
||||
allowH2: false,
|
||||
connect: {
|
||||
autoSelectFamily: false,
|
||||
autoSelectFamilyAttemptTimeout: 300,
|
||||
@@ -184,6 +185,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
|
||||
expect(next).toBeInstanceOf(EnvHttpProxyAgent);
|
||||
expect(next.options?.bodyTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS);
|
||||
expect(next.options?.headersTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS);
|
||||
expect(next.options?.allowH2).toBe(false);
|
||||
expect(next.options?.connect).toEqual({
|
||||
autoSelectFamily: false,
|
||||
autoSelectFamilyAttemptTimeout: 300,
|
||||
@@ -207,6 +209,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
|
||||
expect(next.options?.httpsProxy).toBe("socks5://proxy.test:1080");
|
||||
expect(next.options?.bodyTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS);
|
||||
expect(next.options?.headersTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS);
|
||||
expect(next.options?.allowH2).toBe(false);
|
||||
});
|
||||
|
||||
it("records timeout bridge but does not override unsupported custom proxy dispatcher types", () => {
|
||||
@@ -281,6 +284,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => {
|
||||
autoSelectFamily: false,
|
||||
autoSelectFamilyAttemptTimeout: 300,
|
||||
});
|
||||
expect(next.options?.allowH2).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -299,7 +303,9 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
|
||||
ensureGlobalUndiciEnvProxyDispatcher();
|
||||
|
||||
expect(setGlobalDispatcher).toHaveBeenCalledTimes(1);
|
||||
expect(getCurrentDispatcher()).toBeInstanceOf(EnvHttpProxyAgent);
|
||||
const next = getCurrentDispatcher() as { options?: Record<string, unknown> };
|
||||
expect(next).toBeInstanceOf(EnvHttpProxyAgent);
|
||||
expect(next.options?.allowH2).toBe(false);
|
||||
});
|
||||
|
||||
it("installs EnvHttpProxyAgent with explicit ALL_PROXY fallback options", () => {
|
||||
@@ -317,6 +323,7 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => {
|
||||
expect(next.options).toEqual({
|
||||
httpProxy: "socks5://proxy.test:1080",
|
||||
httpsProxy: "socks5://proxy.test:1080",
|
||||
allowH2: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -413,6 +420,7 @@ describe("forceResetGlobalDispatcher", () => {
|
||||
expect((getCurrentDispatcher() as { options?: Record<string, unknown> }).options).toEqual({
|
||||
httpProxy: "http://proxy-b.example:8080",
|
||||
httpsProxy: "http://proxy-b.example:8080",
|
||||
allowH2: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -431,6 +439,7 @@ describe("forceResetGlobalDispatcher", () => {
|
||||
expect((getCurrentDispatcher() as { options?: Record<string, unknown> }).options).toEqual({
|
||||
httpProxy: "http://proxy-all.example:3128",
|
||||
httpsProxy: "http://proxy-all.example:3128",
|
||||
allowH2: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from "./undici-runtime.js";
|
||||
|
||||
export const DEFAULT_UNDICI_STREAM_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const HTTP1_ONLY_DISPATCHER_OPTIONS = Object.freeze({
|
||||
allowH2: false as const,
|
||||
});
|
||||
|
||||
/**
|
||||
* Module-level bridge so `resolveDispatcherTimeoutMs` in fetch-guard.ts
|
||||
@@ -93,7 +96,12 @@ export function ensureGlobalUndiciEnvProxyDispatcher(): void {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setGlobalDispatcher(new EnvHttpProxyAgent(resolveEnvHttpProxyAgentOptions()));
|
||||
setGlobalDispatcher(
|
||||
new EnvHttpProxyAgent({
|
||||
...resolveEnvHttpProxyAgentOptions(),
|
||||
...HTTP1_ONLY_DISPATCHER_OPTIONS,
|
||||
}),
|
||||
);
|
||||
lastAppliedProxyBootstrap = true;
|
||||
} catch {
|
||||
// Best-effort bootstrap only.
|
||||
@@ -120,6 +128,7 @@ function applyGlobalDispatcherStreamTimeouts(params: {
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
...(connect ? { connect } : {}),
|
||||
...HTTP1_ONLY_DISPATCHER_OPTIONS,
|
||||
} as ConstructorParameters<UndiciGlobalDispatcherDeps["EnvHttpProxyAgent"]>[0];
|
||||
runtime.setGlobalDispatcher(new runtime.EnvHttpProxyAgent(proxyOptions));
|
||||
} else {
|
||||
@@ -128,6 +137,7 @@ function applyGlobalDispatcherStreamTimeouts(params: {
|
||||
bodyTimeout: timeoutMs,
|
||||
headersTimeout: timeoutMs,
|
||||
...(connect ? { connect } : {}),
|
||||
...HTTP1_ONLY_DISPATCHER_OPTIONS,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -192,7 +202,7 @@ export function forceResetGlobalDispatcher(): void {
|
||||
lastAppliedProxyBootstrap = false;
|
||||
try {
|
||||
const { Agent, setGlobalDispatcher } = loadUndiciGlobalDispatcherDeps();
|
||||
setGlobalDispatcher(new Agent());
|
||||
setGlobalDispatcher(new Agent(HTTP1_ONLY_DISPATCHER_OPTIONS));
|
||||
} catch {
|
||||
// Best-effort reset only.
|
||||
}
|
||||
@@ -203,9 +213,10 @@ export function forceResetGlobalDispatcher(): void {
|
||||
const { EnvHttpProxyAgent, setGlobalDispatcher } = loadUndiciGlobalDispatcherDeps();
|
||||
const proxyOptions = resolveEnvHttpProxyAgentOptions();
|
||||
setGlobalDispatcher(
|
||||
new EnvHttpProxyAgent(
|
||||
proxyOptions as ConstructorParameters<UndiciGlobalDispatcherDeps["EnvHttpProxyAgent"]>[0],
|
||||
),
|
||||
new EnvHttpProxyAgent({
|
||||
...proxyOptions,
|
||||
...HTTP1_ONLY_DISPATCHER_OPTIONS,
|
||||
} as ConstructorParameters<UndiciGlobalDispatcherDeps["EnvHttpProxyAgent"]>[0]),
|
||||
);
|
||||
lastAppliedProxyBootstrap = true;
|
||||
} catch {
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("isTransientNetworkError", () => {
|
||||
"UND_ERR_SOCKET",
|
||||
"UND_ERR_HEADERS_TIMEOUT",
|
||||
"UND_ERR_BODY_TIMEOUT",
|
||||
"ERR_HTTP2_INVALID_SESSION",
|
||||
"ERR_SSL_WRONG_VERSION_NUMBER",
|
||||
"ERR_SSL_PROTOCOL_RETURNED_AN_ERROR",
|
||||
];
|
||||
@@ -103,6 +104,17 @@ describe("isTransientNetworkError", () => {
|
||||
expect(isTransientNetworkError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for destroyed HTTP/2 sessions from undici", () => {
|
||||
const innerCause = Object.assign(new Error("The session has been destroyed"), {
|
||||
code: "ERR_HTTP2_INVALID_SESSION",
|
||||
});
|
||||
const outerCause = Object.assign(new Error("model call failed"), { cause: innerCause });
|
||||
|
||||
expect(isTransientNetworkError(innerCause)).toBe(true);
|
||||
expect(isTransientNetworkError(outerCause)).toBe(true);
|
||||
expect(isTransientNetworkError(new Error("ERR_HTTP2_INVALID_SESSION"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for Slack request errors that wrap network codes in .original", () => {
|
||||
const error = Object.assign(new Error("A request error occurred: getaddrinfo EAI_AGAIN"), {
|
||||
code: "slack_webapi_request_error",
|
||||
@@ -375,6 +387,12 @@ describe("isTransientUnhandledRejectionError", () => {
|
||||
const rawAddressUnavailable = new Error(
|
||||
"connect EADDRNOTAVAIL 2607:6bc0::10:443 - Local (:::0)",
|
||||
);
|
||||
const destroyedHttp2Session = Object.assign(new Error("The session has been destroyed"), {
|
||||
code: "ERR_HTTP2_INVALID_SESSION",
|
||||
});
|
||||
const wrappedDestroyedHttp2Session = Object.assign(new Error("model call failed"), {
|
||||
cause: destroyedHttp2Session,
|
||||
});
|
||||
const generic = new Error("boom");
|
||||
|
||||
expect(isBenignUncaughtExceptionError(epipe)).toBe(true);
|
||||
@@ -384,6 +402,9 @@ describe("isTransientUnhandledRejectionError", () => {
|
||||
expect(isBenignUncaughtExceptionError(rawHostUnreachable)).toBe(true);
|
||||
expect(isBenignUncaughtExceptionError(addressUnavailable)).toBe(true);
|
||||
expect(isBenignUncaughtExceptionError(rawAddressUnavailable)).toBe(true);
|
||||
expect(isBenignUncaughtExceptionError(destroyedHttp2Session)).toBe(true);
|
||||
expect(isBenignUncaughtExceptionError(wrappedDestroyedHttp2Session)).toBe(true);
|
||||
expect(isBenignUncaughtExceptionError(new Error("ERR_HTTP2_INVALID_SESSION"))).toBe(true);
|
||||
expect(isBenignUncaughtExceptionError(generic)).toBe(false);
|
||||
});
|
||||
it("returns true for transient SQLite errors", () => {
|
||||
|
||||
@@ -67,6 +67,7 @@ const TRANSIENT_NETWORK_CODES = new Set([
|
||||
"UND_ERR_SOCKET",
|
||||
"UND_ERR_HEADERS_TIMEOUT",
|
||||
"UND_ERR_BODY_TIMEOUT",
|
||||
"ERR_HTTP2_INVALID_SESSION",
|
||||
"EPROTO",
|
||||
"ERR_SSL_WRONG_VERSION_NUMBER",
|
||||
"ERR_SSL_PROTOCOL_RETURNED_AN_ERROR",
|
||||
@@ -101,12 +102,13 @@ const BENIGN_UNCAUGHT_EXCEPTION_NETWORK_CODES = new Set([
|
||||
"UND_ERR_CONNECT_TIMEOUT",
|
||||
"UND_ERR_DNS_RESOLVE_FAILED",
|
||||
"UND_ERR_CONNECT",
|
||||
"ERR_HTTP2_INVALID_SESSION",
|
||||
]);
|
||||
|
||||
const TRANSIENT_NETWORK_MESSAGE_CODE_RE =
|
||||
/\b(ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|EPROTO|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|UND_ERR_SOCKET|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT)\b/i;
|
||||
/\b(ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|EPROTO|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|UND_ERR_SOCKET|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT|ERR_HTTP2_INVALID_SESSION)\b/i;
|
||||
const BENIGN_UNCAUGHT_EXCEPTION_NETWORK_MESSAGE_CODE_RE =
|
||||
/\b(ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT)\b/i;
|
||||
/\b(ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|ERR_HTTP2_INVALID_SESSION)\b/i;
|
||||
|
||||
const TRANSIENT_SQLITE_MESSAGE_CODE_RE =
|
||||
/\b(SQLITE_BUSY|SQLITE_CANTOPEN|SQLITE_IOERR|SQLITE_LOCKED)\b/i;
|
||||
|
||||
Reference in New Issue
Block a user