@langchain/langgraph-sdk
Version:
Client library for interacting with the LangGraph API
275 lines (274 loc) • 8.18 kB
JavaScript
const require_error = require("./error.cjs");
const require_reconnect = require("./reconnect.cjs");
//#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 (require_error.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 = require_reconnect.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
exports.DEFAULT_IDLE_RECONNECT = DEFAULT_IDLE_RECONNECT;
exports.IterableReadableStream = IterableReadableStream;
exports.idleReconnectStream = idleReconnectStream;
exports.streamWithRetry = streamWithRetry;
//# sourceMappingURL=stream.cjs.map