From 7440ac5374bb6f6683098afe5d85a51eed972c68 Mon Sep 17 00:00:00 2001 From: linyijie Date: Fri, 3 Jul 2026 10:58:58 +0800 Subject: [PATCH] recorder: don't drop fixtures when SDK closes socket after `data: [DONE]` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDKs like the OpenAI Python SDK close the response socket the moment they consume `data: [DONE]`, before upstream fires its `end` event. The current clientRes.on("close") handler treats that as a mid-stream disconnect, destroys the upstream request, and the downstream `if (clientDisconnected) return "relayed"` branch skips fixture persistence entirely — so record mode silently produces no fixture for any OpenAI-SDK-driven traffic even though the full response was received. Fix: stop destroying upstream on client close (the existing !clientDisconnected guard in onUpstreamData already prevents writes to the closed peer, so nothing is written to a dead socket), and remove the early return in the clientDisconnected branch — reaching that branch now means upstream ran to completion cleanly, so persisting the fixture is safe. --- src/recorder.ts | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/recorder.ts b/src/recorder.ts index d98598e..6ac19bf 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -779,14 +779,16 @@ export async function proxyAndRecord( } } - // If the client disconnected mid-stream, the collected data is likely - // truncated. Saving a partial fixture is worse than saving none — skip - // fixture persistence entirely. + // Client may have closed its socket before upstream fired `end` + // (e.g. the OpenAI Python SDK closes the response immediately after + // consuming `data: [DONE]`). Because we no longer destroy the upstream + // request on client close (see the `clientRes.on("close")` handler + // below), reaching this point means upstream still ran to completion + // and the buffered body is intact — so recording the fixture is safe. if (clientDisconnected) { defaults.logger.warn( - "Client disconnected mid-stream — skipping fixture save to avoid truncated data", + "Client closed connection before upstream end — upstream response completed, recording full fixture", ); - return "relayed"; } // Build RecordedTimings from frame timestamps captured during streaming. @@ -1071,25 +1073,20 @@ function makeUpstreamRequest( // before the first data chunk arrives. if (typeof clientRes.flushHeaders === "function") clientRes.flushHeaders(); streamedToClient = true; - // Stop relaying if the client disconnects mid-stream. - // Check writableFinished to distinguish normal completion (where - // "close" also fires) from premature client disconnects. + // Track client disconnects but do NOT destroy the upstream + // request. Some SDKs (notably the OpenAI Python SDK) close + // their socket the instant they consume `data: [DONE]`, before + // upstream has fired `res.end`. If we destroyed upstream on + // that signal we would lose the tail of the response — or the + // whole response, when the SDK closes right after `[DONE]` — + // and record mode would silently produce no fixture. Letting + // upstream finish means we still capture a complete body; the + // `!clientDisconnected` guard inside `onUpstreamData` already + // prevents further writes to the now-closed client socket, so + // no data is written to a dead peer. clientRes.on("close", () => { if (!clientRes.writableFinished) { clientDisconnected = true; - req.destroy(); - // Stop in-flight buffering immediately rather than draining the - // upstream socket under backpressure: detach the data listener so - // no further chunks accumulate, and free what's already buffered. - // The promise still settles via the 'end'/'error'/'close' the - // destroyed request emits; collapse/recording is skipped because - // the client disconnected. - res.removeListener("data", onUpstreamData); - chunks.length = 0; - bufferedBytes = 0; - frameTimestamps.length = 0; - frameBuffer = ""; - binaryFrameBuffer = Buffer.alloc(0); } }); }