Skip to content

Commit e21afa1

Browse files
committed
fix(proxy): pin Request freshness by identity; retry only connect-stage failures
Two non-blocking follow-ups from the PR TriDefender#34 review: 1. The freshness regression test asserted req.bodyUsed === false inside a mock fetchImpl — vacuous, because a mock never consumes the Request body, so it passed even under the original reuse bug. Pin freshness by object identity instead: record the first Request and assert the retried dispatch receives a different instance. 2. The connect-retry loop re-sent the request on ANY dispatch throw, including failures after the ordered transport already wrote the full request head+body to the wire (socket closed before response headers) — resending in that window could submit the completion twice and consume quota up to three times. sendOrderedUpstreamRequest now flags errors raised after the write completed (err.postWrite), and the retry loop skips flagged errors, narrowing retries to genuine connect-stage failures. Plain-fetch post-headers-write failures remain undetectable at this layer (unchanged, per review).
1 parent 71a9707 commit e21afa1

3 files changed

Lines changed: 32 additions & 13 deletions

File tree

‎src/proxy/handler-resilience.test.ts‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
* HTTP 400 + {"code":3007,...} in the JSON body (no captcha header) must
66
* be treated as a captcha challenge and retried with a fresh token.
77
* 2. Connect-retry freshness: after a connect-level failure, the retried
8-
* dispatch must receive a FRESH Request (body not marked used). A mock
9-
* fetch that throws-then-succeeds does NOT catch the bug (mocks don't
10-
* enforce bodyUsed), so the mock asserts `req.bodyUsed === false` on
11-
* every call per the review's test note.
8+
* dispatch must receive a FRESH Request. Review follow-up #1 (PR #34):
9+
* a `req.bodyUsed === false` assertion is vacuous in a mock (mock fetch
10+
* never consumes the body), so freshness is pinned by OBJECT IDENTITY —
11+
* the second call must receive a different Request instance.
1212
*
1313
* Both tests use the start-plan path with an injected captcha module via
1414
* `mock.module("./captcha.js")` (same technique as captcha-pool.test.ts).
@@ -117,20 +117,25 @@ describe("proxyRequest — start-plan resilience (PR #34 review P1/P3)", () => {
117117
expect(body.choices[0].message.content).toBe("resilience reply");
118118
});
119119

120-
it("retries a connect failure with a FRESH Request (body not marked used)", async () => {
120+
it("retries a connect failure with a FRESH Request (identity-pinned)", async () => {
121121
// Upstream: first call = connect-level failure (the bug shape), second
122-
// call = success. The mock asserts req.bodyUsed === false on EVERY call
123-
// per the review's test note: mocks don't enforce bodyUsed the way real
124-
// fetch does, so the freshness assertion must be explicit.
122+
// call = success. Review follow-up #1 (PR #34): a mock fetch never
123+
// consumes the Request body, so a `req.bodyUsed === false` assertion is
124+
// vacuous — it passes even if the handler re-dispatches the SAME Request
125+
// object. Pin freshness by OBJECT IDENTITY instead: the second call must
126+
// receive a different Request instance than the first.
125127
let calls = 0;
128+
let firstReq: Request | null = null;
126129
const fetchMock = mock(async (req: Request): Promise<Response> => {
127130
calls += 1;
128-
// The freshness assertion: if the handler re-dispatched the SAME
129-
// Request object, this would be true on the second call.
130-
expect(req.bodyUsed).toBe(false);
131131
if (calls === 1) {
132+
firstReq = req;
132133
throw new Error("Unable to connect. Is the computer able to access the url?");
133134
}
135+
// The freshness assertion: re-dispatching the SAME Request object
136+
// would fail this identity check.
137+
expect(firstReq).not.toBeNull();
138+
expect(req).not.toBe(firstReq);
134139
return new Response(ANTHROPIC_OK, { status: 200, headers: { "content-type": "application/json" } });
135140
});
136141

@@ -144,8 +149,8 @@ describe("proxyRequest — start-plan resilience (PR #34 review P1/P3)", () => {
144149

145150
const resp = await proxyRequest(clientReq, "openai", { config: TEST_CONFIG, auth, fetchImpl: fetchMock as any });
146151

147-
// The connect failure was retried with a FRESH Request (bodyUsed false
148-
// on the second call — the freshness assertion inside the mock passed).
152+
// The connect failure was retried with a FRESH Request (identity check
153+
// inside the mock passed).
149154
expect(calls).toBe(2);
150155
expect(resp.status).toBe(200);
151156
const body = await resp.json();

‎src/proxy/handler.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,11 @@ export async function proxyRequest(
243243
upstreamResp = await dispatch(upstreamReq, upstreamHeaderPairs);
244244
break;
245245
} catch (err) {
246+
// Review follow-up #2 (PR #34): if the ordered transport already
247+
// wrote the full request to the wire before failing, the upstream
248+
// may have processed it — resending could consume quota twice. Skip
249+
// the retry for those post-write failures.
250+
if ((err as { postWrite?: boolean }).postWrite) throw err;
246251
if (attempt >= maxConnectAttempts) throw err;
247252
const backoffMs = 500 * attempt;
248253
if (debug) debugError(reqId, "upstream_connect_retry", `attempt ${attempt}/${maxConnectAttempts - 1} failed (${(err as Error).message}), retrying in ${backoffMs}ms`);

‎src/proxy/ordered-transport.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export async function sendOrderedUpstreamRequest(req: OrderedUpstreamRequest): P
2626
return await new Promise<Response>((resolve, reject) => {
2727
let headerBuffer: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
2828
let responseStarted = false;
29+
let postWrite = false;
2930
let bodyController: ReadableStreamDefaultController<Uint8Array> | null = null;
3031
let chunkedDecoder: ChunkedDecoder | null = null;
3132
let remainingContentLength: number | null = null;
@@ -40,6 +41,13 @@ export async function sendOrderedUpstreamRequest(req: OrderedUpstreamRequest): P
4041
});
4142

4243
function fail(err: unknown): void {
44+
if (!responseStarted && postWrite) {
45+
// Review follow-up #2 (PR #34): the full request (head + body) was
46+
// already written to the wire, so the upstream may have processed it
47+
// — resending could duplicate the LLM call and consume quota twice.
48+
// Flag it so the connect-retry loop in handler.ts skips this error.
49+
try { (err as { postWrite?: boolean }).postWrite = true; } catch {}
50+
}
4351
if (responseStarted) bodyController?.error(err);
4452
else reject(err);
4553
socket.destroy();
@@ -127,6 +135,7 @@ export async function sendOrderedUpstreamRequest(req: OrderedUpstreamRequest): P
127135

128136
socket.write(requestHead);
129137
if (bodyBytes.byteLength > 0) socket.write(bodyBytes);
138+
postWrite = true;
130139
});
131140
}
132141

0 commit comments

Comments
 (0)