mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): remove Codex SSE retry abort listener after backoff (#11725)
The Codex Responses provider's local `sleep()` helper attached an
anonymous `abort` listener to the caller's signal and never removed it
when the timer fired. The SSE retry loop calls this helper before each
retry, so every completed backoff left one listener on the signal. Three
429 responses followed by a success left three listeners behind.
The helper now uses a named `onAbort` handler, removes it when the timer
fires, and registers it with `{ once: true }`. It still rejects with
`Error("Request was aborted")`, which the retry loop's catch block
depends on.
### Testing
- New regression test in `packages/ai/test/openai-codex-stream.test.ts`.
A stub fetch returns 429 with `retry-after: 0` three times and then a
completed SSE stream, using `maxRetries: 3` and the caller's own
`AbortController`. The test asserts that `getEventListeners(signal,
"abort")` is empty afterwards. It fails without the change ("got 3") and
passes with it.
- `node ../../node_modules/vitest/dist/cli.js --run
test/openai-codex-stream.test.ts`: 41/41 pass.
- A standalone script against the built package printed `listeners 3`
before the change and `listeners 0` after.
- `npm run check`: biome and the repo scripts pass. `tsc` fails, but
only in files this change does not touch: generated model ids in
`packages/ai` tests, `github-copilot.ts`, and missing bench dependencies
in `packages/schema`. I did not verify whether those failures also
happen on a clean checkout.
Fixes #11708
Requested by @badlogic
Co-authored-by: triagent <triagent@localhost>
This commit is contained in:
parent
d7704516e0
commit
90b42bca6e
2 changed files with 63 additions and 3 deletions
|
|
@ -185,11 +185,15 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|||
reject(new Error("Request was aborted"));
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
signal?.addEventListener("abort", () => {
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Request was aborted"));
|
||||
});
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { getEventListeners } from "node:events";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
|
@ -320,6 +321,61 @@ describe("openai-codex streaming", () => {
|
|||
expect(result.endTurn).toBe(false);
|
||||
});
|
||||
|
||||
// Regression test for #11708
|
||||
it("removes SSE retry backoff abort listeners after the retry succeeds", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
||||
process.env.PI_CODING_AGENT_DIR = tempDir;
|
||||
const token = mockToken();
|
||||
let calls = 0;
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url !== "https://chatgpt.com/backend-api/codex/responses") {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
calls++;
|
||||
if (calls <= 3) {
|
||||
return new Response(JSON.stringify({ error: { message: "rate limited" } }), {
|
||||
status: 429,
|
||||
headers: { "retry-after": "0" },
|
||||
});
|
||||
}
|
||||
return new Response(buildSSEPayload({ status: "completed" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const model: Model<"openai-codex-responses"> = {
|
||||
id: "gpt-5.1-codex",
|
||||
name: "GPT-5.1 Codex",
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const result = await streamOpenAICodexResponses(model, context, {
|
||||
apiKey: token,
|
||||
transport: "sse",
|
||||
maxRetries: 3,
|
||||
signal: controller.signal,
|
||||
}).result();
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(calls).toBe(4);
|
||||
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("maps response.incomplete to stopReason length even when the SSE body stays open", async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
||||
process.env.PI_CODING_AGENT_DIR = tempDir;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue