Hey team !
Summary
Aborting a tool-enabled callModel() request while consuming getFullResponsesStream() can produce two rejections:
- The broadcaster consumer rejects with
AbortError.
- The background
executionPromise rejects independently.
The caller can catch the stream error, but the async generator exits while iterating the consumer and never reaches its later await executionPromise. The second rejection therefore becomes unhandled.
With Node.js's default unhandled-rejection behavior, this can terminate the entire server process.
Affected versions
@openrouter/agent@0.7.0
@openrouter/agent@0.9.0
- Current
main at 787cbf8b22bf2b8071e81e2dbf84ecd871a5e824
- Observed in production on Node.js 22
- Reproduced deterministically on Node.js 24.11.1
Reproduction
import { OpenRouter, tool } from "@openrouter/agent";
import { z } from "zod";
const client = new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
const controller = new AbortController();
const noopTool = tool({
name: "noop",
description: "A tool that is not expected to be called.",
inputSchema: z.object({}),
execute: async () => ({ ok: true }),
});
// This handler demonstrates the second rejection.
// Without it, Node.js may terminate the process.
process.on("unhandledRejection", (error) => {
console.error("UNHANDLED REJECTION:", error);
});
const result = client.callModel(
{
model: process.env.OPENROUTER_MODEL ?? "openai/gpt-5-nano",
input: "Write a sufficiently long response.",
tools: [noopTool],
},
{
signal: controller.signal,
},
);
let aborted = false;
try {
for await (const event of result.getFullResponsesStream()) {
if (!aborted) {
aborted = true;
controller.abort();
}
console.log(event.type);
}
} catch (error) {
console.log("Stream error caught by caller:", error);
}
// Allow Node.js to report any unhandled promise rejection.
await new Promise((resolve) => setTimeout(resolve, 100));
Typical result:
Stream error caught by caller: DOMException [AbortError]
UNHANDLED REJECTION: DOMException [AbortError]
Without an unhandledRejection listener, Node.js 22 terminates with output similar to:
node:internal/process/promises:394
triggerUncaughtException(err, true /* fromPromise */);
^
DOMException [AbortError]: This operation was aborted
The tool does not need to be called. Merely configuring a non-empty tools array selects the broadcaster/tool-execution streaming path.
Expected behavior
The abort should be exposed once through the stream getter, allowing the caller to handle it with try/catch.
No additional process-level unhandledRejection should occur.
Root cause
startTurnBroadcasterExecution() starts tool execution in the background:
const executionPromise = this.executeToolsIfNeeded().finally(async () => {
if (this.initialPipePromise) {
await this.initialPipePromise;
}
broadcaster.complete();
});
The stream getters then consume the broadcaster before awaiting that promise:
const { consumer, executionPromise } =
this.startTurnBroadcasterExecution();
for await (const event of consumer) {
yield event;
}
await executionPromise;
If consumer throws during an abort, control exits the async generator before await executionPromise is reached. The independently rejected promise has no handler.
Because several stream getters use startTurnBroadcasterExecution(), the same failure mode may affect more than getFullResponsesStream().
Suggested fix
Observe executionPromise immediately after creating it:
const executionPromise = this.executeToolsIfNeeded().finally(async () => {
if (this.initialPipePromise) {
await this.initialPipePromise;
}
broadcaster.complete();
});
void executionPromise.catch(() => {});
This does not replace or resolve the original promise. A later await executionPromise still throws the original error to the caller; the immediate handler only prevents the background rejection from becoming process-level unhandled.
A centralized stream helper that always settles executionPromise when consumer iteration fails would also solve the problem.
Suggested regression test
The test should verify that:
- Aborting a tool-enabled stream rejects the consumer with
AbortError.
- No
unhandledRejection is emitted after the next event-loop turn.
- The original
executionPromise remains rejected and observable.
Impact
HTTP servers commonly forward the incoming request's AbortSignal to the model request. A mobile app closing, browser navigation, or network disconnection can therefore terminate the complete Node.js server rather than only canceling one generation.
Hey team !
Summary
Aborting a tool-enabled
callModel()request while consuminggetFullResponsesStream()can produce two rejections:AbortError.executionPromiserejects independently.The caller can catch the stream error, but the async generator exits while iterating the consumer and never reaches its later
await executionPromise. The second rejection therefore becomes unhandled.With Node.js's default unhandled-rejection behavior, this can terminate the entire server process.
Affected versions
@openrouter/agent@0.7.0@openrouter/agent@0.9.0mainat787cbf8b22bf2b8071e81e2dbf84ecd871a5e824Reproduction
Typical result:
Without an
unhandledRejectionlistener, Node.js 22 terminates with output similar to:The tool does not need to be called. Merely configuring a non-empty
toolsarray selects the broadcaster/tool-execution streaming path.Expected behavior
The abort should be exposed once through the stream getter, allowing the caller to handle it with
try/catch.No additional process-level
unhandledRejectionshould occur.Root cause
startTurnBroadcasterExecution()starts tool execution in the background:The stream getters then consume the broadcaster before awaiting that promise:
If
consumerthrows during an abort, control exits the async generator beforeawait executionPromiseis reached. The independently rejected promise has no handler.Because several stream getters use
startTurnBroadcasterExecution(), the same failure mode may affect more thangetFullResponsesStream().Suggested fix
Observe
executionPromiseimmediately after creating it:This does not replace or resolve the original promise. A later
await executionPromisestill throws the original error to the caller; the immediate handler only prevents the background rejection from becoming process-level unhandled.A centralized stream helper that always settles
executionPromisewhen consumer iteration fails would also solve the problem.Suggested regression test
The test should verify that:
AbortError.unhandledRejectionis emitted after the next event-loop turn.executionPromiseremains rejected and observable.Impact
HTTP servers commonly forward the incoming request's
AbortSignalto the model request. A mobile app closing, browser navigation, or network disconnection can therefore terminate the complete Node.js server rather than only canceling one generation.