earendil-works/pi · info
Request aborted by user
Error message
Request aborted by user
What it means
Mid-stream cancellation check in streamProxy: after each SSE chunk is read from the proxy, the client re-checks options.signal and throws this if it was aborted (proxy.ts:190). The partial AssistantMessage accumulated so far is preserved and delivered as an error event with reason/stopReason 'aborted', so content received before the abort is not lost.
Source
Thrown at packages/agent/src/proxy.ts:190
if (errorData.error) {
errorMessage = `Proxy error: ${errorData.error}`;
}
} catch {
// Couldn't parse error response
}
throw new Error(errorMessage);
}
reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (options.signal?.aborted) {
throw new Error("Request aborted by user");
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6).trim();
if (data) {
const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
const event = processProxyEvent(proxyEvent, partial);
if (event) {
stream.push(event);
}
}
}
}View on GitHub (pinned to 4af9d21d3b)
Solutions
- Handle stopReason 'aborted' as a normal outcome and keep the partial content already delivered
- Do not auto-retry aborted streams - that undoes the user's cancellation
- Ensure only the owner of the controller aborts it; hunt down accidental aborts from timeouts or unmounts
- Pass a signal scoped to exactly one stream, never a reused long-lived controller
Example fix
// before
for await (const ev of streamProxy(model, context, opts)) { render(ev); }
// after - keep partial output on abort
const stream = streamProxy(model, context, opts);
const final = await stream.result;
if (final.stopReason === "aborted") {
renderPartial(final); // content array holds everything streamed pre-abort
} Defensive patterns
Strategy: type-guard
Validate before calling
if (options.signal?.aborted) {
// don't even start the stream
return cancelledStream();
}
const stream = streamProxy(model, context, options); Type guard
const isAbortedMessage = (m: AssistantMessage): m is AssistantMessage & { stopReason: "aborted" } =>
m.stopReason === "aborted"; Try / catch
// no catch needed: aborts are encoded as events per the StreamFn contract
const stream = streamProxy(model, context, opts);
const msg = await stream.result;
if (isAbortedMessage(msg)) {
keepPartialContent(msg); // msg.content holds everything streamed pre-abort
return;
} Prevention
- Scope one AbortController to exactly one stream and abort it only in response to user intent
- Handle stopReason 'aborted' as a first-class outcome in your UI
- Never auto-retry an aborted stream
When it happens
Trigger: User hits stop while tokens are streaming; the AbortController passed as options.signal fires from a UI unmount, navigation, or timeout between chunk reads; the abort handler also cancels the reader, ending the fetch body.
Common situations: Stop/cancel buttons in chat UIs; React components aborting on unmount (including strict-mode double mounts); per-request timeout controllers expiring during long generations.
Related errors
AI-assisted analysis of earendil-works/pi@4af9d21d3b (2026-08-24).
Data as JSON: /api/errors/74fde55c1f79960b.
Report an issue: GitHub.