UNPKG

@langchain/langgraph-sdk

Version:

Client library for interacting with the LangGraph API

272 lines (271 loc) • 8.03 kB
import { isNetworkError } from "./error.js"; import { reconnectDelayMs } from "./reconnect.js"; //#region src/utils/stream.ts /** * Error thrown when maximum reconnection attempts are exceeded. */ var MaxReconnectAttemptsError = class extends Error { constructor(maxAttempts, cause) { super(`Exceeded maximum SSE reconnection attempts (${maxAttempts})`); this.name = "MaxReconnectAttemptsError"; this.cause = cause; } }; /** * Error injected into the stream by {@link idleReconnectStream} when no lines * arrive within the active idle window. Surfacing this during the read is what * lets the reconnect loops in `streamWithRetry` and the protocol SSE transport * recover from a half-open socket — one that was silently dropped (e.g. a hard * pod kill on a platform revision rollover) without a TCP FIN/RST, so neither * a `done` nor a thrown network error ever arrives. */ var StreamIdleTimeoutError = class extends Error { idleTimeoutMs; constructor(idleTimeoutMs) { super(`No SSE bytes received for ${idleTimeoutMs}ms; assuming the connection is half-open and reconnecting.`); this.name = "StreamIdleTimeoutError"; this.idleTimeoutMs = idleTimeoutMs; } }; /** `":"` — first byte of an SSE comment / keep-alive line. */ const SSE_COMMENT_BYTE = 58; /** * Default idle-reconnect policy for SSE streams. Heartbeat-adaptive * `"auto"` stays dormant unless the server emits keep-alive heartbeats. * Pass `0` to disable. */ const DEFAULT_IDLE_RECONNECT = "auto"; /** * A pass-through {@link TransformStream} that errors the stream when it goes * idle, so the surrounding reconnect logic can recover a half-open socket. * * MUST sit on the *line* stream — i.e. after * {@link import("./sse.js").BytesLineDecoder} but before * {@link import("./sse.js").SSEDecoder} (which discards `:` comment lines). * Operating at the line level lets the watchdog both (a) reset on any line * (data *or* heartbeat = liveness) and (b) recognise heartbeat comment lines * to drive `"auto"` mode. * * In `"auto"` mode the watchdog is intentionally dormant until it has seen at * least two heartbeats (so it can measure the cadence). This means a socket * that dies inside the first heartbeat interval won't be caught until a * heartbeat would have been due — an acceptable trade for never false-firing * on heartbeat-less servers. Pass a fixed `number` if you need coverage from * the very first byte. */ function idleReconnectStream(options) { const factor = options.timeoutFactor ?? 3; const minTimeoutMs = options.minTimeoutMs ?? 6e3; const maxTimeoutMs = options.maxTimeoutMs ?? 3e4; const fixedTimeoutMs = typeof options.mode === "number" ? options.mode : null; let timer; let controllerRef; let lastHeartbeatAt; let derivedTimeoutMs = fixedTimeoutMs; const clear = () => { if (timer != null) { clearTimeout(timer); timer = void 0; } }; const arm = () => { clear(); const timeoutMs = derivedTimeoutMs; if (timeoutMs == null || timeoutMs <= 0) return; timer = setTimeout(() => { options.onIdle?.({ timeoutMs, source: fixedTimeoutMs != null ? "fixed" : "heartbeat" }); try { controllerRef?.error(new StreamIdleTimeoutError(timeoutMs)); } catch {} }, timeoutMs); timer.unref?.(); }; const noteHeartbeat = () => { if (fixedTimeoutMs != null) return; const now = Date.now(); if (lastHeartbeatAt != null) { const interval = now - lastHeartbeatAt; if (interval > 0) { const candidate = Math.min(Math.max(interval * factor, minTimeoutMs), maxTimeoutMs); derivedTimeoutMs = derivedTimeoutMs == null ? candidate : Math.max(derivedTimeoutMs, candidate); } } lastHeartbeatAt = now; }; return new TransformStream({ start(controller) { controllerRef = controller; arm(); }, transform(line, controller) { if (line.length > 0 && line[0] === SSE_COMMENT_BYTE) noteHeartbeat(); arm(); controller.enqueue(line); }, flush() { clear(); } }); } /** * Stream with automatic retry logic for SSE connections. * Implements reconnection behavior similar to the Python SDK. * * @param makeRequest Function to make requests. When `params` is undefined/empty, it's the initial request. * When `params.reconnectPath` is provided, it's a reconnection request. * @param options Configuration options * @returns AsyncGenerator yielding stream events */ async function* streamWithRetry(makeRequest, options = {}) { const maxRetries = options.maxRetries ?? 5; let attempt = 0; let lastEventId; let reconnectPath; while (true) { let shouldRetry = false; let lastError; let reader; try { if (options.signal?.aborted) return; const { response, stream } = await makeRequest(reconnectPath ? { lastEventId, reconnectPath } : void 0); const locationHeader = response.headers.get("location"); if (locationHeader) reconnectPath = locationHeader; const contentType = response.headers.get("content-type")?.split(";")[0]; if (contentType && !contentType.includes("text/event-stream")) throw new Error(`Expected response header Content-Type to contain 'text/event-stream', got '${contentType}'`); reader = stream.getReader(); try { while (true) { if (options.signal?.aborted) { await reader.cancel(); return; } const { done, value } = await reader.read(); if (done) break; if (value.id) lastEventId = value.id; yield value; } break; } catch (error) { if (reconnectPath && !options.signal?.aborted) shouldRetry = true; else throw error; } finally { if (reader) try { reader.releaseLock(); } catch {} } } catch (error) { lastError = error; if (isNetworkError(error) && reconnectPath && !options.signal?.aborted) shouldRetry = true; else throw error; } if (shouldRetry) { attempt += 1; if (attempt > maxRetries) throw new MaxReconnectAttemptsError(maxRetries, lastError); options.onReconnect?.({ attempt, lastEventId, cause: lastError }); const delay = reconnectDelayMs(attempt); await new Promise((resolve) => { setTimeout(resolve, delay); }); continue; } break; } } var IterableReadableStream = class IterableReadableStream extends ReadableStream { reader; ensureReader() { if (!this.reader) this.reader = this.getReader(); } async next() { this.ensureReader(); try { const result = await this.reader.read(); if (result.done) { this.reader.releaseLock(); return { done: true, value: void 0 }; } else return { done: false, value: result.value }; } catch (e) { this.reader.releaseLock(); throw e; } } async return() { this.ensureReader(); if (this.locked) { const cancelPromise = this.reader.cancel(); this.reader.releaseLock(); await cancelPromise; } return { done: true, value: void 0 }; } async throw(e) { this.ensureReader(); if (this.locked) { const cancelPromise = this.reader.cancel(); this.reader.releaseLock(); await cancelPromise; } throw e; } async [Symbol.asyncDispose]() { await this.return(); } [Symbol.asyncIterator]() { return this; } static fromReadableStream(stream) { const reader = stream.getReader(); return new IterableReadableStream({ start(controller) { return pump(); function pump() { return reader.read().then(({ done, value }) => { if (done) { controller.close(); return; } controller.enqueue(value); return pump(); }); } }, cancel() { reader.releaseLock(); } }); } static fromAsyncGenerator(generator) { return new IterableReadableStream({ async pull(controller) { const { value, done } = await generator.next(); if (done) controller.close(); controller.enqueue(value); }, async cancel(reason) { await generator.return(reason); } }); } }; //#endregion export { DEFAULT_IDLE_RECONNECT, IterableReadableStream, idleReconnectStream, streamWithRetry }; //# sourceMappingURL=stream.js.map