can1357/oh-my-pi · warning · AIError.StreamTimeoutError

options.errorMessage

Error message

options.errorMessage

What it means

AIError.StreamTimeoutError thrown by iterateWithIdleTimeout when no item has arrived within options.idleTimeoutMs of the last progress timestamp (or since start) and no local work is pending. Unlike the first-item variant, this is the general idle path: the stream started (or was expected to) but stalled. The message is exactly options.errorMessage.

Source

Thrown at packages/ai/src/utils/idle-iterator.ts:349

				if (firstItemDeadlineMs !== undefined) {
					activeTimeoutMs = firstItemDeadlineMs - Date.now();
					if (activeTimeoutMs <= 0) {
						if (!hasPendingLocalWork()) {
							options.onFirstItemTimeout?.();
							closeIterator();
							throw new AIError.StreamTimeoutError(options.firstItemErrorMessage ?? options.errorMessage);
						}
						extendDeadlineForLocalWork();
						activeTimeoutMs = firstItemDeadlineMs! - Date.now();
					}
				}
			} else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
				activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
				if (activeTimeoutMs <= 0) {
					if (!hasPendingLocalWork()) {
						options.onIdle?.();
						closeIterator();
						throw new AIError.StreamTimeoutError(options.errorMessage);
					}
					extendDeadlineForLocalWork();
					activeTimeoutMs = options.idleTimeoutMs;
				}
			}

			pendingNext ??= withRacy(iterator.next());

			const racers: Array<
				Promise<
					| { kind: "next"; result: IteratorResult<T> }
					| { kind: "error"; error: unknown }
					| { kind: "timeout" }
					| { kind: "abort" }
				>
			> = [pendingNext];

			const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs > 0;

View on GitHub (pinned to 9690622007)

Solutions

  1. Raise idleTimeoutMs to exceed the longest legitimate gap between stream events (e.g. extended thinking).
  2. Handle the error with retry/resume logic — reconnect and continue if the provider supports resumable streams.
  3. Use options.onIdle as a hook to log or send keep-alive before the throw.
  4. Enable TCP/HTTP keep-alive at the fetch layer to survive NAT idle drops.

Example fix

// before
iterateWithIdleTimeout(stream, { idleTimeoutMs: 5_000, errorMessage: "stream timeout" });
// after
iterateWithIdleTimeout(stream, { idleTimeoutMs: 60_000, errorMessage: "stream idle >60s, aborting" });
Defensive patterns

Strategy: retry

Try / catch

try {
	for await (const ev of timedStream) handle(ev);
} catch (err) {
	if (err instanceof AIError.StreamTimeoutError) {
		logger.warn("stream stalled past idleTimeoutMs — reconnecting");
		await reconnectAndResume();
	} else throw err;
}

Prevention

When it happens

Trigger: A wrapped stream (timedAnthropic/timedOpenai/codex SSE/etc.) goes silent longer than idleTimeoutMs between items — provider stopped sending chunks mid-generation, connection hung without RST, or an intermediary buffering indefinitely.

Common situations: Long tool-use/thinking phases where the provider legitimately sends nothing (idle timeout set too low); NAT or load balancer dropping an idle TCP connection; provider-side generation stall.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/11412e80f206e7df. Report an issue: GitHub.