Skip to content

fix(ai-llm-proxy): prevent mid-flight request cancellation from wasting LLM cost - #535

Merged
LukasHirt merged 3 commits into
mainfrom
fix-ai-llm-proxy-mid-flight-cancellation
Jul 29, 2026
Merged

fix(ai-llm-proxy): prevent mid-flight request cancellation from wasting LLM cost#535
LukasHirt merged 3 commits into
mainfrom
fix-ai-llm-proxy-mid-flight-cancellation

Conversation

@LukasHirt

@LukasHirt LukasHirt commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Proactive follow-up (no tracked issue) addressing user-reported "AI proxy requests cancelled mid-flight" behavior. Prior research identified two independent root causes across the client → proxy → upstream LLM request chain, both fixed here.

Root cause 1 — client-side timeouts fire before the proxy's own timeout, and are inconsistent

Every web-app-ai-* (and a couple non-AI) composable calling the ai-llm-proxy set its own AbortSignal.timeout(...), with inconsistent values of either 30s or 60s. The proxy itself allows up to 60s for its own upstream LLM fetch, plus unaccounted latency before that for OIDC discovery/userinfo validation. Any composable using the 30s value could abort a request the proxy was still legitimately working on.

Fix: raised and unified every client-side AbortSignal.timeout(...) call that hits the ai-llm-proxy to 90_000 (90s) — comfortably above the proxy's 60s upstream timeout — with a one-line comment at each call site. Left untouched: the 10s vision-capability probe in web-app-ai-image-alt-text-sidebar, and web-app-ai-smart-collections-nav's useRecentFiles.ts REQUEST_TIMEOUT_MS (a WebDAV REPORT/getFileContents timeout, not an LLM call).

Also added missing TimeoutError-specific error handling (matching the message style already used by sibling packages) to the two composables that lacked it entirely:

  • web-app-ai-sensitive-data-scanner (useLlm.ts / useScanner.ts)
  • web-app-ai-quick-draft-creator (useLLM.ts) — this one needed the outbound fetch wrapped in its own try/catch, since a timeout abort rejects the fetch itself before there's a Response to check .ok on.

Root cause 2 — the proxy never notices the client disconnected

In packages/ai-llm-proxy/src/index.ts's handleRequest, once the outbound fetch to the LLM endpoint started, there was no listener for the client request's 'close' event. If the client's own timeout fired, the tab closed, or the connection dropped, the proxy kept awaiting the full upstream LLM response (up to 60s) — burning LLM cost for a response nobody would receive — then tried to write to an already-dead response.

Fix:

  • Wired an AbortController into handleRequest, aborted on the request's 'close' event.
  • Combined that controller's signal with the existing upstream timeout via AbortSignal.any([...]), so either condition stops the outbound fetch.
  • Guarded sendJson and the final response write against a disconnected client (res.writableEnded/res.destroyed checks).
  • Attached a no-op res.on('error', () => {}) listener so a write-after-close can never crash the process via an unhandled 'error' event.
  • Added unit tests proving the outbound fetch's abort signal fires on client disconnect, and that no write/crash occurs afterwards.

Verification

Per touched package, ran (or the equivalent for ai-llm-proxy, which has no check:types/lint script, so tsc/scoped eslint were used instead):

  • check:types — all pass
  • lint — 0 errors (only pre-existing warnings unrelated to these changes)
  • test:unit — all pass, including 5 new tests in packages/ai-llm-proxy/tests/unit/proxy.spec.ts (28/28 total in that file)

Packages covered: ai-llm-proxy, web-app-ai-data-insights-sidebar, web-app-ai-doc-summary, web-app-ai-folder-brief-sidebar, web-app-ai-folder-readme-generator, web-app-ai-image-alt-text-sidebar, web-app-ai-multi-doc-synthesizer, web-app-ai-quick-draft-creator, web-app-ai-sensitive-data-scanner, web-app-ai-smart-collections-nav, web-app-ai-smart-file-tagger-qa, web-app-chat-with-file, web-app-version-changelog.

Test plan

  • ai-llm-proxy unit tests cover the new disconnect-abort behavior and write-after-close safety
  • Every touched web-app package's existing unit tests still pass with the new timeout value
  • Manual read-through of every call site to confirm the 90s value and comment were applied correctly, and that unrelated timeouts (10s vision probe, WebDAV REPORT timeout) were left alone

Follow-up: client timeout raised from 90s to a 5-minute safety net

The 90_000 (90s) client-side timeout above was chosen to comfortably exceed the proxy's hardcoded 60s upstream timeout. However, a separate, still-open PR (#530, feat/ai-llm-proxy-configurable-timeout) makes that upstream timeout configurable via LLM_TIMEOUT_MS (default unchanged at 60s, but with no fixed upper bound). If an admin sets LLM_TIMEOUT_MS above 90s to accommodate a slower self-hosted model, the client's 90s timeout would again fire before the proxy's configured timeout — reintroducing the exact mid-flight cancellation bug this PR fixes, just at a different threshold.

Fix: raised every client composable's AbortSignal.timeout(...) from 90_000 to 300_000 (5 minutes), and reworded the accompanying comments accordingly. The client-side timeout is no longer meant to closely track the proxy's own upstream timeout — it's now a generous, fixed outer bound whose only job is to prevent the browser tab from hanging indefinitely if the network or the proxy process itself never responds at all (e.g. a killed container, a dropped TCP connection with no FIN, or a proxy that hangs before even starting its upstream fetch). The proxy's own (separately configurable) timeout remains the authoritative, real cutoff for how long an LLM call is allowed to run.

Left untouched: the proxy's own AbortSignal.timeout(60_000) in packages/ai-llm-proxy/src/index.ts (governed by #530, out of scope here), the 10s vision-capability probe in web-app-ai-image-alt-text-sidebar, and web-app-ai-smart-collections-nav's useRecentFiles.ts WebDAV timeout.

Verified check:types, lint, and test:unit pass for all 12 touched web-app-* packages after the change.

@LukasHirt
LukasHirt requested a review from a team as a code owner July 27, 2026 12:16
@kw-security

kw-security commented Jul 27, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

The proxy never listened for the client connection closing, so an
AbortSignal.timeout firing client-side, a closed tab, or a dropped
connection left the outbound LLM fetch running for up to its own
60s timeout — burning LLM cost for a response nobody could receive,
then attempting to write to an already-dead response.

Wire an AbortController into handleRequest that aborts on req's
'close' event and is combined with the existing upstream timeout via
AbortSignal.any(), so either condition stops the outbound fetch.
Guard sendJson and the final response write against a disconnected
client, and attach a no-op res error listener so a late write-after-
close can never crash the process with an unhandled 'error' event.

Add unit tests proving the outbound fetch signal aborts on client
disconnect and that no write/crash happens afterwards.

Signed-off-by: Lukas Hirt <info@hirt.cz>
Every ai-llm-proxy caller set its own AbortSignal.timeout with wildly
inconsistent values (30s or 60s), while the proxy's own upstream fetch
allows up to 60s plus unaccounted latency for OIDC discovery/userinfo
validation. Callers using the 30s value could abort mid-flight while
the proxy was still legitimately working, cancelling a request that
would otherwise have succeeded.

Raise and unify every client-side timeout that calls the ai-llm-proxy
to 90s — comfortably above the proxy's 60s upstream timeout plus
margin — across web-app-ai-data-insights-sidebar, web-app-ai-doc-summary,
web-app-ai-folder-brief-sidebar, web-app-ai-folder-readme-generator,
web-app-ai-image-alt-text-sidebar (main call only; the separate 10s
vision-capability probe is intentionally untouched), web-app-ai-multi-
doc-synthesizer, web-app-ai-quick-draft-creator, web-app-ai-sensitive-
data-scanner, web-app-ai-smart-collections-nav, web-app-ai-smart-file-
tagger-qa, web-app-chat-with-file, and web-app-version-changelog.
(web-app-ai-smart-collections-nav's useRecentFiles.ts REQUEST_TIMEOUT_MS
is a WebDAV REPORT/getFileContents timeout, not an LLM proxy call, and
is left untouched.)

Also add TimeoutError-specific error handling to the two composables
that were missing it entirely: web-app-ai-sensitive-data-scanner's
useLlm/useScanner and web-app-ai-quick-draft-creator's useLLM (which
required wrapping the fetch call itself in try/catch, since a timeout
abort rejects before there's a Response to check .ok on).

Signed-off-by: Lukas Hirt <info@hirt.cz>
…ceiling

The proxy's upstream LLM timeout will become configurable via LLM_TIMEOUT_MS
in a separate PR (#530), with a default of 60s but no fixed upper bound. A
hardcoded 90s client-side timeout could again fire before a larger
admin-configured proxy timeout, recreating the mid-flight cancellation issue
this branch fixes. Raise every client composable's AbortSignal.timeout from
90_000 to 300_000 (5 minutes) so it acts purely as a generous outer bound
against a hung network or dead proxy process, rather than an attempt to
closely track the proxy's own (separately configurable) timeout.

Signed-off-by: Lukas Hirt <info@hirt.cz>
@LukasHirt
LukasHirt force-pushed the fix-ai-llm-proxy-mid-flight-cancellation branch from 7fa5e3c to 3fe12ae Compare July 27, 2026 12:47
@LukasHirt
LukasHirt merged commit 371dadb into main Jul 29, 2026
34 checks passed
@LukasHirt
LukasHirt deleted the fix-ai-llm-proxy-mid-flight-cancellation branch July 29, 2026 09:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants