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:
Josh Avant
2026-05-14 11:36:59 -05:00
committed by GitHub
parent 0de6f93805
commit d0f22ccf97
5 changed files with 52 additions and 8 deletions

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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