From 0d75575ea3cb446b60d0e140d6f8294cafabc7aa Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Tue, 28 Jul 2026 20:33:17 -0400 Subject: [PATCH 1/2] fix(agentex-ui): abort the upstream request when the BFF client disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/api/agentex` catch-all proxy streams SSE through unbuffered but passes no signal to `fetch`, so when the browser goes away — closed tab, navigation, or the 300s Istio route timeout — undici keeps the upstream request open. Each orphan leaves an agentex handler blocked in a Redis XREAD, pinning one connection from that pod's pool until it is exhausted and agentex can no longer serve requests. There is no timeout on the call either, so an orphan never expires. Pass `req.signal`, which App Router aborts on client disconnect, and answer the resulting fetch rejection with a 499 so a departed client is not reported as an upstream failure. A fixed timeout would be wrong here — it would cut legitimate long-lived SSE streams. Co-Authored-By: Claude Opus 5 (1M context) --- agentex-ui/app/api/agentex/[...path]/route.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/agentex-ui/app/api/agentex/[...path]/route.ts b/agentex-ui/app/api/agentex/[...path]/route.ts index e0579dd5..c070fc50 100644 --- a/agentex-ui/app/api/agentex/[...path]/route.ts +++ b/agentex-ui/app/api/agentex/[...path]/route.ts @@ -34,14 +34,24 @@ async function proxy( const method = req.method.toUpperCase(); const hasBody = method !== 'GET' && method !== 'HEAD'; - const upstream = await fetch(target, { - method, - headers, - body: hasBody ? req.body : undefined, - redirect: 'manual', - // @ts-expect-error `duplex` is required to stream a request body (undici) - duplex: 'half', - }); + let upstream: Response; + try { + upstream = await fetch(target, { + method, + headers, + body: hasBody ? req.body : undefined, + redirect: 'manual', + // A client disconnect must tear down the upstream request; undici otherwise holds it + // open and the upstream handler keeps running. + signal: req.signal, + // @ts-expect-error `duplex` is required to stream a request body (undici) + duplex: 'half', + }); + } catch (error) { + // The disconnect above aborts `req.signal`, which rejects the fetch — not an upstream failure. + if (req.signal.aborted) return new Response(null, { status: 499 }); + throw error; + } // Pass the upstream body through unbuffered so SSE / streaming responses work. const resHeaders = new Headers(upstream.headers); From c78215b7d08354de632fe2b251dcd3425bf4caa9 Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Tue, 28 Jul 2026 20:50:38 -0400 Subject: [PATCH 2/2] fix(agentex-ui): match the abort by rejection identity, not error name Addresses review feedback: `req.signal.aborted` alone can mask a genuine transport failure that happens to coincide with a client disconnect, since the flag is already set by the time the catch runs. Match on `error === req.signal.reason` instead. `fetch` rejects with the signal's abort reason itself, so identity separates the two cases without consulting the flag at all. Matching on `error.name === 'AbortError'` would not work here: Next's abort reason is a `ResponseAborted`, and the class name is minified in a production build, so both name checks fail and every disconnect would rethrow as a 500. Co-Authored-By: Claude Opus 5 (1M context) --- agentex-ui/app/api/agentex/[...path]/route.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/agentex-ui/app/api/agentex/[...path]/route.ts b/agentex-ui/app/api/agentex/[...path]/route.ts index c070fc50..5a6baa71 100644 --- a/agentex-ui/app/api/agentex/[...path]/route.ts +++ b/agentex-ui/app/api/agentex/[...path]/route.ts @@ -48,8 +48,10 @@ async function proxy( duplex: 'half', }); } catch (error) { - // The disconnect above aborts `req.signal`, which rejects the fetch — not an upstream failure. - if (req.signal.aborted) return new Response(null, { status: 499 }); + // The disconnect above rejects the fetch with the signal's abort reason itself, so identity — + // not `error.name` — separates it from a transport failure that coincides with a disconnect. + // Next's reason is a `ResponseAborted`, so an `AbortError` check would never fire. + if (error === req.signal.reason) return new Response(null, { status: 499 }); throw error; }