fix: propagate timeoutMs to guarded dispatchers (local LLM 60s timeout)#70831
Conversation
97a63f4 to
21feabb
Compare
Greptile SummaryThis PR threads Confidence Score: 5/5Safe to merge — the fix is correct, all changes are additive and backward compatible, and SSRF protection is unaffected. The only open finding is a P2 style inconsistency in
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/infra/net/undici-runtime.ts
Line: 87-103
Comment:
**`createHttp1ProxyAgent` bypasses the shared helper**
`createHttp1Agent` and `createHttp1EnvHttpProxyAgent` both delegate to `withHttp1OnlyDispatcherOptions`, which centralises the `allowH2: false` invariant and timeout logic. `createHttp1ProxyAgent` was refactored to inline those properties manually instead of calling the helper. If anything is added to `withHttp1OnlyDispatcherOptions` in the future (e.g. a new invariant beyond `allowH2`), `createHttp1ProxyAgent` will silently miss it.
Consider keeping the same shape as the other two functions:
```ts
export function createHttp1ProxyAgent(
options: UndiciProxyAgentOptions,
timeoutMs?: number,
): import("undici").ProxyAgent {
const { ProxyAgent } = loadUndiciRuntimeDeps();
const normalised =
typeof options === "string" || options instanceof URL
? { uri: options.toString() }
: { ...options };
return new ProxyAgent(
withHttp1OnlyDispatcherOptions(normalised as UndiciAgentOptions, timeoutMs) as UndiciProxyAgentOptions,
);
}
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (2): Last reviewed commit: "chore: re-run CI smoke tests" | Re-trigger Greptile |
Gemini Investigation ReportGenerated by Gemini on 2026-04-23 as part of the timeout propagation investigation. Original report file: `openclaw-gemini-report.md` SummaryLocal LLM requests via `openai-completions` are being killed at exactly 60 seconds despite a configured 15-minute timeout in `openclaw.json`. The investigation confirms that while the 15-minute timeout is correctly applied to an `AbortSignal`, it is not passed to the underlying Undici dispatchers (Agent, ProxyAgent, etc.). Undici's default `headersTimeout` and `bodyTimeout` are 60 seconds, which causes the connection to be killed before the `AbortSignal` fires. File-by-File Analysis1. `src/agents/provider-transport-fetch.ts`
2. `src/infra/net/fetch-guard.ts`
3. `src/infra/net/undici-runtime.ts`
4. `src/infra/net/undici-global-dispatcher.ts`
5. `src/agents/pi-embedded-runner/run/attempt-http-runtime.ts`
6. `src/infra/net/ssrf.ts`
7. `src/agents/openai-transport-stream.ts`
8. `src/utils/fetch-timeout.ts` (referenced as `src/infra/net/fetch-timeout.ts`)
Exact Point of FailureThe timeout is lost at `src/infra/net/fetch-guard.ts` and `src/infra/net/ssrf.ts` because they do not propagate the `timeoutMs` to the Undici `Agent` constructor. This leads to Undici using its internal default of 60,000ms for `headersTimeout` and `bodyTimeout`. Minimal Fix Proposal
Side Effects / Concerns
|
d575217 to
c76e8c0
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d5752175a2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const opts = (globalDispatcher as { options?: Record<string, unknown> }).options; | ||
| if (opts?.bodyTimeout) { | ||
| return typeof opts.bodyTimeout === "number" ? opts.bodyTimeout : undefined; |
There was a problem hiding this comment.
Resolve fallback timeout without relying on
.options
resolveDispatcherTimeoutMs reads getGlobalDispatcher().options.bodyTimeout, but Undici’s Agent/EnvHttpProxyAgent do not expose their constructor options through a public .options field, so this branch returns undefined in normal runtime. In the provider transport path, buildGuardedModelFetch is still commonly called without an explicit timeoutMs, so guarded dispatchers continue to be created without bodyTimeout/headersTimeout and requests can still terminate at Undici’s ~60s defaults instead of the configured attempt timeout.
Useful? React with 👍 / 👎.
c76e8c0 to
cacd9fb
Compare
|
Updated with bridge variable fix for the |
|
@codex review |
|
/retrigger greptile |
|
Codex Review: Didn't find any major issues. What shall we delve into next? ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
|
CI re-run passed (all green). @greptile-apps / @codex please re-review the bridge variable fix in . |
|
Codex Review: Didn't find any major issues. Delightful! ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
7cb7495 to
a9e8aa2
Compare
Thread timeoutMs through the dispatcher creation chain so that per-request (guarded) dispatchers honor the configured LLM timeout instead of falling back to undici's hardcoded 60s bodyTimeout/headersTimeout. Changes: - undici-runtime.ts: createHttp1Agent/ProxyAgent/EnvHttpProxyAgent now accept timeoutMs and apply bodyTimeout/headersTimeout to dispatcher options - ssrf.ts: createPinnedDispatcher accepts timeoutMs and passes it through - fetch-guard.ts: fetchWithSsrFGuard reads timeout from params or falls back to global dispatcher bodyTimeout via getGlobalDispatcher() - provider-transport-fetch.ts: buildGuardedModelFetch accepts optional timeoutMs and passes it to fetchWithSsrFGuard The global dispatcher timeout (set by ensureGlobalUndiciStreamTimeouts) is still applied to non-guarded requests. Guarded requests (used by LLM transports) now also receive the timeout via a fallback to the global dispatcher when not explicitly provided. Fixes openclaw#70829
Replace dead-code .options.bodyTimeout read in resolveDispatcherTimeoutMs with a module-level bridge (_globalUndiciStreamTimeoutMs) set by ensureGlobalUndiciStreamTimeouts. This avoids reliance on Undici's non-public .options field and ensures guarded dispatchers inherit the configured stream timeout instead of falling back to undici's 60s default. Fixes Greptile P1 and Codex comments on PR openclaw#70831
a9e8aa2 to
c015e67
Compare
|
Landed via squash onto main.
Thanks @DranboFieldston! |
…t) (openclaw#70831) * fix: propagate timeoutMs to guarded dispatchers Thread timeoutMs through the dispatcher creation chain so that per-request (guarded) dispatchers honor the configured LLM timeout instead of falling back to undici's hardcoded 60s bodyTimeout/headersTimeout. Changes: - undici-runtime.ts: createHttp1Agent/ProxyAgent/EnvHttpProxyAgent now accept timeoutMs and apply bodyTimeout/headersTimeout to dispatcher options - ssrf.ts: createPinnedDispatcher accepts timeoutMs and passes it through - fetch-guard.ts: fetchWithSsrFGuard reads timeout from params or falls back to global dispatcher bodyTimeout via getGlobalDispatcher() - provider-transport-fetch.ts: buildGuardedModelFetch accepts optional timeoutMs and passes it to fetchWithSsrFGuard The global dispatcher timeout (set by ensureGlobalUndiciStreamTimeouts) is still applied to non-guarded requests. Guarded requests (used by LLM transports) now also receive the timeout via a fallback to the global dispatcher when not explicitly provided. Fixes openclaw#70829 * fix: resolve fallback timeout via module-level bridge variable Replace dead-code .options.bodyTimeout read in resolveDispatcherTimeoutMs with a module-level bridge (_globalUndiciStreamTimeoutMs) set by ensureGlobalUndiciStreamTimeouts. This avoids reliance on Undici's non-public .options field and ensures guarded dispatchers inherit the configured stream timeout instead of falling back to undici's 60s default. Fixes Greptile P1 and Codex comments on PR openclaw#70831 * chore: re-run CI smoke tests * test: cover guarded dispatcher timeout propagation * test: align timeout bridge expectation * docs: note guarded dispatcher timeout fix --------- Co-authored-by: Peter Steinberger <[email protected]>
…t) (openclaw#70831) * fix: propagate timeoutMs to guarded dispatchers Thread timeoutMs through the dispatcher creation chain so that per-request (guarded) dispatchers honor the configured LLM timeout instead of falling back to undici's hardcoded 60s bodyTimeout/headersTimeout. Changes: - undici-runtime.ts: createHttp1Agent/ProxyAgent/EnvHttpProxyAgent now accept timeoutMs and apply bodyTimeout/headersTimeout to dispatcher options - ssrf.ts: createPinnedDispatcher accepts timeoutMs and passes it through - fetch-guard.ts: fetchWithSsrFGuard reads timeout from params or falls back to global dispatcher bodyTimeout via getGlobalDispatcher() - provider-transport-fetch.ts: buildGuardedModelFetch accepts optional timeoutMs and passes it to fetchWithSsrFGuard The global dispatcher timeout (set by ensureGlobalUndiciStreamTimeouts) is still applied to non-guarded requests. Guarded requests (used by LLM transports) now also receive the timeout via a fallback to the global dispatcher when not explicitly provided. Fixes openclaw#70829 * fix: resolve fallback timeout via module-level bridge variable Replace dead-code .options.bodyTimeout read in resolveDispatcherTimeoutMs with a module-level bridge (_globalUndiciStreamTimeoutMs) set by ensureGlobalUndiciStreamTimeouts. This avoids reliance on Undici's non-public .options field and ensures guarded dispatchers inherit the configured stream timeout instead of falling back to undici's 60s default. Fixes Greptile P1 and Codex comments on PR openclaw#70831 * chore: re-run CI smoke tests * test: cover guarded dispatcher timeout propagation * test: align timeout bridge expectation * docs: note guarded dispatcher timeout fix --------- Co-authored-by: Peter Steinberger <[email protected]>
…t) (openclaw#70831) * fix: propagate timeoutMs to guarded dispatchers Thread timeoutMs through the dispatcher creation chain so that per-request (guarded) dispatchers honor the configured LLM timeout instead of falling back to undici's hardcoded 60s bodyTimeout/headersTimeout. Changes: - undici-runtime.ts: createHttp1Agent/ProxyAgent/EnvHttpProxyAgent now accept timeoutMs and apply bodyTimeout/headersTimeout to dispatcher options - ssrf.ts: createPinnedDispatcher accepts timeoutMs and passes it through - fetch-guard.ts: fetchWithSsrFGuard reads timeout from params or falls back to global dispatcher bodyTimeout via getGlobalDispatcher() - provider-transport-fetch.ts: buildGuardedModelFetch accepts optional timeoutMs and passes it to fetchWithSsrFGuard The global dispatcher timeout (set by ensureGlobalUndiciStreamTimeouts) is still applied to non-guarded requests. Guarded requests (used by LLM transports) now also receive the timeout via a fallback to the global dispatcher when not explicitly provided. Fixes openclaw#70829 * fix: resolve fallback timeout via module-level bridge variable Replace dead-code .options.bodyTimeout read in resolveDispatcherTimeoutMs with a module-level bridge (_globalUndiciStreamTimeoutMs) set by ensureGlobalUndiciStreamTimeouts. This avoids reliance on Undici's non-public .options field and ensures guarded dispatchers inherit the configured stream timeout instead of falling back to undici's 60s default. Fixes Greptile P1 and Codex comments on PR openclaw#70831 * chore: re-run CI smoke tests * test: cover guarded dispatcher timeout propagation * test: align timeout bridge expectation * docs: note guarded dispatcher timeout fix --------- Co-authored-by: Peter Steinberger <[email protected]>
…t) (openclaw#70831) * fix: propagate timeoutMs to guarded dispatchers Thread timeoutMs through the dispatcher creation chain so that per-request (guarded) dispatchers honor the configured LLM timeout instead of falling back to undici's hardcoded 60s bodyTimeout/headersTimeout. Changes: - undici-runtime.ts: createHttp1Agent/ProxyAgent/EnvHttpProxyAgent now accept timeoutMs and apply bodyTimeout/headersTimeout to dispatcher options - ssrf.ts: createPinnedDispatcher accepts timeoutMs and passes it through - fetch-guard.ts: fetchWithSsrFGuard reads timeout from params or falls back to global dispatcher bodyTimeout via getGlobalDispatcher() - provider-transport-fetch.ts: buildGuardedModelFetch accepts optional timeoutMs and passes it to fetchWithSsrFGuard The global dispatcher timeout (set by ensureGlobalUndiciStreamTimeouts) is still applied to non-guarded requests. Guarded requests (used by LLM transports) now also receive the timeout via a fallback to the global dispatcher when not explicitly provided. Fixes openclaw#70829 * fix: resolve fallback timeout via module-level bridge variable Replace dead-code .options.bodyTimeout read in resolveDispatcherTimeoutMs with a module-level bridge (_globalUndiciStreamTimeoutMs) set by ensureGlobalUndiciStreamTimeouts. This avoids reliance on Undici's non-public .options field and ensures guarded dispatchers inherit the configured stream timeout instead of falling back to undici's 60s default. Fixes Greptile P1 and Codex comments on PR openclaw#70831 * chore: re-run CI smoke tests * test: cover guarded dispatcher timeout propagation * test: align timeout bridge expectation * docs: note guarded dispatcher timeout fix --------- Co-authored-by: Peter Steinberger <[email protected]>
…t) (openclaw#70831) * fix: propagate timeoutMs to guarded dispatchers Thread timeoutMs through the dispatcher creation chain so that per-request (guarded) dispatchers honor the configured LLM timeout instead of falling back to undici's hardcoded 60s bodyTimeout/headersTimeout. Changes: - undici-runtime.ts: createHttp1Agent/ProxyAgent/EnvHttpProxyAgent now accept timeoutMs and apply bodyTimeout/headersTimeout to dispatcher options - ssrf.ts: createPinnedDispatcher accepts timeoutMs and passes it through - fetch-guard.ts: fetchWithSsrFGuard reads timeout from params or falls back to global dispatcher bodyTimeout via getGlobalDispatcher() - provider-transport-fetch.ts: buildGuardedModelFetch accepts optional timeoutMs and passes it to fetchWithSsrFGuard The global dispatcher timeout (set by ensureGlobalUndiciStreamTimeouts) is still applied to non-guarded requests. Guarded requests (used by LLM transports) now also receive the timeout via a fallback to the global dispatcher when not explicitly provided. Fixes openclaw#70829 * fix: resolve fallback timeout via module-level bridge variable Replace dead-code .options.bodyTimeout read in resolveDispatcherTimeoutMs with a module-level bridge (_globalUndiciStreamTimeoutMs) set by ensureGlobalUndiciStreamTimeouts. This avoids reliance on Undici's non-public .options field and ensures guarded dispatchers inherit the configured stream timeout instead of falling back to undici's 60s default. Fixes Greptile P1 and Codex comments on PR openclaw#70831 * chore: re-run CI smoke tests * test: cover guarded dispatcher timeout propagation * test: align timeout bridge expectation * docs: note guarded dispatcher timeout fix --------- Co-authored-by: Peter Steinberger <[email protected]>
…t) (openclaw#70831) * fix: propagate timeoutMs to guarded dispatchers Thread timeoutMs through the dispatcher creation chain so that per-request (guarded) dispatchers honor the configured LLM timeout instead of falling back to undici's hardcoded 60s bodyTimeout/headersTimeout. Changes: - undici-runtime.ts: createHttp1Agent/ProxyAgent/EnvHttpProxyAgent now accept timeoutMs and apply bodyTimeout/headersTimeout to dispatcher options - ssrf.ts: createPinnedDispatcher accepts timeoutMs and passes it through - fetch-guard.ts: fetchWithSsrFGuard reads timeout from params or falls back to global dispatcher bodyTimeout via getGlobalDispatcher() - provider-transport-fetch.ts: buildGuardedModelFetch accepts optional timeoutMs and passes it to fetchWithSsrFGuard The global dispatcher timeout (set by ensureGlobalUndiciStreamTimeouts) is still applied to non-guarded requests. Guarded requests (used by LLM transports) now also receive the timeout via a fallback to the global dispatcher when not explicitly provided. Fixes openclaw#70829 * fix: resolve fallback timeout via module-level bridge variable Replace dead-code .options.bodyTimeout read in resolveDispatcherTimeoutMs with a module-level bridge (_globalUndiciStreamTimeoutMs) set by ensureGlobalUndiciStreamTimeouts. This avoids reliance on Undici's non-public .options field and ensures guarded dispatchers inherit the configured stream timeout instead of falling back to undici's 60s default. Fixes Greptile P1 and Codex comments on PR openclaw#70831 * chore: re-run CI smoke tests * test: cover guarded dispatcher timeout propagation * test: align timeout bridge expectation * docs: note guarded dispatcher timeout fix --------- Co-authored-by: Peter Steinberger <[email protected]>
Problem
Local LLM requests via
openai-completions(and other provider transports) are killed at exactly 60 seconds, despite havingtimeoutSeconds: 900(15 min) configured inopenclaw.json.Root Cause
Timeout propagation gap in the guarded fetch path.
OpenClaw correctly sets a global undici dispatcher timeout via
ensureGlobalUndiciStreamTimeouts()inattempt-http-runtime.ts. However, the LLM transport path (openai-transport-stream.ts,anthropic-transport-stream.ts) usesbuildGuardedModelFetch(), which callsfetchWithSsrFGuard().fetchWithSsrFGuard()creates isolated dispatchers per-request for SSRF security (DNS pinning, private network isolation). These isolated dispatchers callcreateHttp1Agent(),createHttp1EnvHttpProxyAgent(),createHttp1ProxyAgent(), orcreatePinnedDispatcher()fromundici-runtime.ts— and none of them currently accept or apply thetimeoutMsparameter. They fall back to undici's hardcoded default of 60s forbodyTimeoutandheadersTimeout.The 15-minute
AbortSignalis correctly created (viabuildTimeoutAbortSignal()), but undici kills the connection at 60s before the AbortSignal fires at 15 min.The Flow
```
openclaw.json → timeoutSeconds: 900 (15 min)
↓
attempt.ts → params.timeoutMs = 900000
↓
attempt-http-runtime.ts → ensureGlobalUndiciStreamTimeouts({ timeoutMs }) ✅ (global dispatcher, 15 min)
↓
openai-transport-stream.ts → buildGuardedModelFetch(model) ❌ (no timeoutMs passed)
↓
provider-transport-fetch.ts → fetchWithSsrFGuard({ ..., timeoutMs: undefined }) ❌ (passed to AbortSignal only)
↓
fetch-guard.ts → createHttp1Agent() / createPinnedDispatcher() ❌ (no timeoutMs)
↓
undici-runtime.ts → new Agent() with no bodyTimeout/headersTimeout → undici default 60s ❌
```
Investigation
Proposed Fix
Thread `timeoutMs` through the dispatcher creation chain:
Minimal Patch (4 files)
`src/infra/net/undici-runtime.ts`
headersTimeoutto dispatcher options`src/infra/net/ssrf.ts`
`src/infra/net/fetch-guard.ts`
`src/agents/provider-transport-fetch.ts`
Risks & Considerations
Verification
Fixes #70829