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

  1. Handle stopReason 'aborted' as a normal outcome and keep the partial content already delivered
  2. Do not auto-retry aborted streams - that undoes the user's cancellation
  3. Ensure only the owner of the controller aborts it; hunt down accidental aborts from timeouts or unmounts
  4. 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

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.