OpenAI Responses upstream stream continues after downstream client disconnects
Summary
For OpenAI OAuth accounts handled by src/routes/openaiRoutes.js, a streaming POST /openai/v1/responses request can continue consuming the ChatGPT Codex upstream stream after the downstream client has disconnected during the response.
The upstream eventually reaches response.completed and usage is recorded even though the client is gone. If the client retries after its own timeout, the same long generation can run multiple times, multiplying account load and usage.
This is a cancellation-propagation issue, not a lack of SSE support: chunks are streamed correctly while the connection is alive.
Affected code on current main
The OpenAI OAuth path creates its Axios request without an AbortController.signal:
const axiosConfig = {
headers,
timeout: config.requestTimeout || 600000,
validateStatus: () => true
}After the upstream response has been established, cleanup only listens on the incoming request:
const cleanup = () => {
try {
upstream.data?.unpipe?.(res)
upstream.data?.destroy?.()
} catch (_) {
//
}
}
req.on('close', cleanup)
req.on('aborted', cleanup)There is no res.on('close', cleanup). For a response-phase disconnect, the incoming request body may already be complete and its close event may already have fired before these listeners are attached. req.aborted covers an aborted incoming request, not a client that disconnects while reading a long response.
The separate openaiResponsesRelayService.js already demonstrates the more complete pattern by using an AbortController and listening to both request and response close events, but OpenAI OAuth accounts do not use that path.
Reproduction
- Send a long-running request to
/openai/v1/responseswithstream: truethrough an OpenAI OAuth account. - Confirm that SSE events are arriving.
- Close the downstream client before
response.completed(for example, let a client-side total timeout expire). - Observe the relay after the client disconnects.
Actual behavior
- The downstream connection closes.
- The relay continues consuming the upstream Codex stream.
- A later
response.completedevent is processed and full usage is recorded. - Retrying clients can create several concurrent/repeated generations of the same request.
In one production observation, five client attempts timed out while active SSE was being received. All five upstream generations continued after disconnect and later recorded approximately 12k-13k output tokens each.
Expected behavior
- A downstream response close immediately aborts the corresponding Axios request and destroys the upstream stream.
- Normal completion removes the close listeners.
- Cleanup is idempotent and does not affect other concurrent requests.
- A cancelled request must not later be treated as a successful completed generation by the relay.
Suggested implementation
- Create a request-local
AbortControllerbefore calling Axios. - Pass
signal: abortController.signalinaxiosConfig. - Use one idempotent cleanup function for
req.abortedandres.close. - In cleanup, abort the controller, unpipe/destroy the upstream stream if present, and avoid writing to a destroyed response.
- Remove listeners on normal upstream
end/terminal completion. - Treat Axios cancellation as an expected downstream cancellation rather than an upstream account failure.
Suggested regression tests
- Mock an upstream server that emits SSE events periodically and delays
response.completed. - Start a relay request and close the downstream client after several events.
- Assert that the upstream socket/request is aborted promptly.
- Assert that no completed usage is recorded after cancellation.
- Run a second concurrent request and assert that cancelling the first does not affect it.
I verified the affected shape against the current upstream main on 2026-08-03. I can prepare a focused PR if this approach is acceptable.
Source: Wei-Shaw/claude-relay-service