@0xinsider/sdk 0.14.0-bootstrap.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +674 -0
- package/dist/client.d.ts +1652 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2127 -0
- package/dist/client.js.map +1 -0
- package/dist/data-quality.d.ts +74 -0
- package/dist/data-quality.d.ts.map +1 -0
- package/dist/data-quality.js +68 -0
- package/dist/data-quality.js.map +1 -0
- package/dist/errors.d.ts +400 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +700 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/pagination.d.ts +196 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +208 -0
- package/dist/pagination.js.map +1 -0
- package/dist/provenance.d.ts +8 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +15 -0
- package/dist/provenance.js.map +1 -0
- package/dist/retry.d.ts +68 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +125 -0
- package/dist/retry.js.map +1 -0
- package/dist/schema.d.ts +4910 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +7 -0
- package/dist/schema.js.map +1 -0
- package/dist/stream.d.ts +509 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +932 -0
- package/dist/stream.js.map +1 -0
- package/dist/webhooks.d.ts +314 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +153 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +64 -0
package/dist/stream.js
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE consumer for `GET /api/v1/stream`.
|
|
3
|
+
*
|
|
4
|
+
* The endpoint forwards the platform's live feed envelopes as Server-Sent
|
|
5
|
+
* Events. Source of truth: `web/public/api/v1/openapi.json` -> paths./api/v1/stream.
|
|
6
|
+
*
|
|
7
|
+
* Wire format (per the spec's response description):
|
|
8
|
+
* - Each data frame is `id: <seq>\ndata: <json-envelope>\n\n`, where the JSON
|
|
9
|
+
* envelope is `{ seq, published_at, type, ...event-specific }`. The SSE id
|
|
10
|
+
* equals the envelope `seq`.
|
|
11
|
+
* - A resync marker is `event: resync\nid: <seq>\ndata: <json>\n\n`, where the
|
|
12
|
+
* JSON is `{ type: "resync", completeness, from_sequence, to_sequence }`.
|
|
13
|
+
* It means the requested resume point is outside the retained window or a
|
|
14
|
+
* live sequence gap was observed; treat it as "refetch current state".
|
|
15
|
+
* - Idle connections emit `: keep-alive` comment lines (ignored).
|
|
16
|
+
*
|
|
17
|
+
* Resume is via the `Last-Event-ID` header set to the last `seq` you processed;
|
|
18
|
+
* the spec also accepts `last_event_id` / `seq` query fallbacks. This consumer
|
|
19
|
+
* sends the header and auto-tracks the last seen seq so a caller-driven
|
|
20
|
+
* reconnect resumes from the right place.
|
|
21
|
+
*
|
|
22
|
+
* Filters (per-connection, subscribe-time): `event` (comma-separated frame
|
|
23
|
+
* types), `condition_id`, and `min_grade` (S|A|B|C|D|F).
|
|
24
|
+
*
|
|
25
|
+
* Protocol validity (#16248). The decoder is bounded and fails visibly:
|
|
26
|
+
* - A successful response whose media type is not `text/event-stream` is
|
|
27
|
+
* `StreamProtocolError` (`unexpected_media_type`), not an empty stream.
|
|
28
|
+
* - A data frame whose payload is not JSON, not an object, or has no usable
|
|
29
|
+
* sequence (a finite `seq` in the envelope, or a finite SSE `id`) is a
|
|
30
|
+
* `StreamProtocolError` (`invalid_json`, `invalid_envelope`,
|
|
31
|
+
* `unusable_sequence`); a resync frame whose payload is not an object is
|
|
32
|
+
* `invalid_resync`. Before #16248 such frames were skipped or yielded with
|
|
33
|
+
* `NaN`, and the next valid frame moved the cursor past the gap.
|
|
34
|
+
* - A frame larger than `maxFrameBytes` (default 1 MiB), whether or not its
|
|
35
|
+
* blank-line delimiter has arrived, is `frame_too_large` before it is
|
|
36
|
+
* parsed (#16644); the reader is released and the connection closed.
|
|
37
|
+
* - The error carries `lastSeq`, the last sequence delivered before it, so a
|
|
38
|
+
* malformed frame never moves the cursor and a caller can decide whether
|
|
39
|
+
* to resume from there (which replays the frame while it is retained),
|
|
40
|
+
* skip past `frameId`, or refetch state. No raw payload is on the error.
|
|
41
|
+
* - A terminal `event: error` frame (the key was revoked, the account
|
|
42
|
+
* lapsed, or the credential store could not confirm the key) throws the
|
|
43
|
+
* typed `OxinsiderApiError` it carries and is never yielded (#16546);
|
|
44
|
+
* `retry: false` is permanent to the resilient consumers.
|
|
45
|
+
* Comment lines (`: keep-alive`), LF and CRLF framing, unknown SSE fields
|
|
46
|
+
* and unknown-but-valid envelope `type`s are compatible as before.
|
|
47
|
+
*/
|
|
48
|
+
import { resolveApiUrl } from "./client.js";
|
|
49
|
+
import { errorFromResponse, OxinsiderApiError } from "./errors.js";
|
|
50
|
+
import { RETRY_AFTER_CEILING_MS, retryAfterSeconds, waitUnlessAborted, } from "./retry.js";
|
|
51
|
+
/** Default for `StreamOptions.maxFrameBytes`: 1 MiB. */
|
|
52
|
+
export const DEFAULT_MAX_STREAM_FRAME_BYTES = 1_048_576;
|
|
53
|
+
/**
|
|
54
|
+
* The stream broke the SSE contract (#16248). The connection is closed and
|
|
55
|
+
* the reader released before this is thrown. `lastSeq` is the last sequence
|
|
56
|
+
* delivered on this connection, or `undefined` when none was: no malformed
|
|
57
|
+
* frame moves the cursor. `frameId` is the SSE `id` of the offending frame
|
|
58
|
+
* when it carried one, `event` its SSE event name, `bytes` its size, and
|
|
59
|
+
* `mediaType` the response's `Content-Type` for `unexpected_media_type`. The
|
|
60
|
+
* raw payload is deliberately not carried: it may hold data you would not
|
|
61
|
+
* want in a log, and `frameId` plus `lastSeq` name the frame exactly.
|
|
62
|
+
*
|
|
63
|
+
* `streamFeedResilient` treats this as permanent: reconnecting from
|
|
64
|
+
* `lastSeq` would replay the same frame while the server retains it. Decide:
|
|
65
|
+
* resume from `lastSeq` later, resume after `frameId` to skip it, or refetch
|
|
66
|
+
* state and attach live.
|
|
67
|
+
*/
|
|
68
|
+
export class StreamProtocolError extends Error {
|
|
69
|
+
reason;
|
|
70
|
+
lastSeq;
|
|
71
|
+
frameId;
|
|
72
|
+
event;
|
|
73
|
+
bytes;
|
|
74
|
+
mediaType;
|
|
75
|
+
constructor(reason, detail) {
|
|
76
|
+
super(streamProtocolMessage(reason, detail));
|
|
77
|
+
this.name = "StreamProtocolError";
|
|
78
|
+
this.reason = reason;
|
|
79
|
+
this.lastSeq = detail.lastSeq;
|
|
80
|
+
this.frameId = detail.frameId;
|
|
81
|
+
this.event = detail.event;
|
|
82
|
+
this.bytes = detail.bytes;
|
|
83
|
+
this.mediaType = detail.mediaType ?? undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function streamProtocolMessage(reason, detail) {
|
|
87
|
+
const where = detail.frameId === undefined
|
|
88
|
+
? "a frame"
|
|
89
|
+
: `frame id ${String(detail.frameId)}`;
|
|
90
|
+
const after = detail.lastSeq === undefined
|
|
91
|
+
? "before any event was delivered"
|
|
92
|
+
: `after seq ${String(detail.lastSeq)}`;
|
|
93
|
+
switch (reason) {
|
|
94
|
+
case "unexpected_media_type":
|
|
95
|
+
return `0xinsider stream answered ${detail.mediaType ? `Content-Type ${detail.mediaType}` : "with no Content-Type"} instead of text/event-stream; not an SSE stream`;
|
|
96
|
+
case "invalid_json":
|
|
97
|
+
return `0xinsider stream sent ${where} whose data is not JSON (${String(detail.bytes ?? 0)} bytes) ${after}`;
|
|
98
|
+
case "invalid_envelope":
|
|
99
|
+
return `0xinsider stream sent ${where} whose data is not an envelope object ${after}`;
|
|
100
|
+
case "unusable_sequence":
|
|
101
|
+
return `0xinsider stream sent ${where} with no finite seq and no finite id ${after}`;
|
|
102
|
+
case "invalid_resync":
|
|
103
|
+
return `0xinsider stream sent a resync marker (${where}) whose data is not a resync object ${after}`;
|
|
104
|
+
case "frame_too_large":
|
|
105
|
+
return `0xinsider stream sent a frame past ${String(detail.bytes ?? 0)} bytes with no delimiter ${after}; the connection was closed`;
|
|
106
|
+
default:
|
|
107
|
+
return `0xinsider stream protocol error ${after}`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function assertMaxFrameBytes(value) {
|
|
111
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
112
|
+
throw new Error(`maxFrameBytes must be a positive integer, got ${String(value)}`);
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
/** `Content-Type` names an event stream: `text/event-stream`, with or without parameters. */
|
|
117
|
+
export function isEventStreamMediaType(contentType) {
|
|
118
|
+
if (contentType === null)
|
|
119
|
+
return false;
|
|
120
|
+
const essence = contentType.split(";", 1)[0]?.trim().toLowerCase();
|
|
121
|
+
return essence === "text/event-stream";
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Open the stream and async-iterate its frames. Yields a discriminated
|
|
125
|
+
* `StreamEvent`: `kind: "event"` carries the `FeedEnvelope`, `kind: "resync"`
|
|
126
|
+
* carries the `ResyncMarker`.
|
|
127
|
+
*
|
|
128
|
+
* Pass `options.cursor` (a `{ seq?: number }` object) to have the last
|
|
129
|
+
* delivered `seq` written back as frames arrive; reconnect with
|
|
130
|
+
* `lastEventId: cursor.seq` to resume after a transport drop.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* for await (const frame of streamFeed(client, { event: ["WhaleTradesInserted"], min_grade: "S" })) {
|
|
134
|
+
* if (frame.kind === "event") console.log(frame.envelope.type, frame.seq);
|
|
135
|
+
* }
|
|
136
|
+
*/
|
|
137
|
+
export async function* streamFeed(client, options = {}) {
|
|
138
|
+
const maxFrameBytes = assertMaxFrameBytes(options.maxFrameBytes ?? DEFAULT_MAX_STREAM_FRAME_BYTES);
|
|
139
|
+
const apiKey = client.getApiKey();
|
|
140
|
+
// The sandbox takes no credential; it answers the stream route with a 400
|
|
141
|
+
// saying streams are not simulated, and that answer is the server's to
|
|
142
|
+
// give (#16138). Production refuses a keyless stream, so say so locally.
|
|
143
|
+
if (!apiKey && !client.isSandbox()) {
|
|
144
|
+
throw new Error("getStream requires an API key (oxi_sk_*)");
|
|
145
|
+
}
|
|
146
|
+
const url = buildStreamUrl(client, options);
|
|
147
|
+
const headers = new Headers({ accept: "text/event-stream" });
|
|
148
|
+
if (apiKey)
|
|
149
|
+
headers.set("authorization", `Bearer ${apiKey}`);
|
|
150
|
+
if (options.lastEventId !== undefined) {
|
|
151
|
+
headers.set("last-event-id", String(options.lastEventId));
|
|
152
|
+
}
|
|
153
|
+
const fetchImpl = client.getFetch();
|
|
154
|
+
const response = await fetchImpl(url, {
|
|
155
|
+
method: "GET",
|
|
156
|
+
headers,
|
|
157
|
+
signal: options.signal,
|
|
158
|
+
});
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
const body = await safeText(response);
|
|
161
|
+
throw errorFromResponse(response.status, tryParse(body), retryAfterSeconds(response), { requestId: response.headers.get("x-request-id") });
|
|
162
|
+
}
|
|
163
|
+
// A 2xx that is not an event stream (an HTML page from a proxy, a JSON body
|
|
164
|
+
// from a route that moved) used to read as an empty stream that closed
|
|
165
|
+
// cleanly, which the reconnect loop then retried as an outage (#16248).
|
|
166
|
+
const mediaType = response.headers.get("content-type");
|
|
167
|
+
if (!isEventStreamMediaType(mediaType)) {
|
|
168
|
+
await response.body?.cancel().catch(() => undefined);
|
|
169
|
+
throw new StreamProtocolError("unexpected_media_type", {
|
|
170
|
+
lastSeq: undefined,
|
|
171
|
+
mediaType,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (!response.body) {
|
|
175
|
+
throw new Error("Stream response has no readable body");
|
|
176
|
+
}
|
|
177
|
+
const reader = response.body.getReader();
|
|
178
|
+
const decoder = new TextDecoder();
|
|
179
|
+
const encoder = new TextEncoder();
|
|
180
|
+
let buffer = "";
|
|
181
|
+
// Bytes of `buffer`: exact, since every chunk adds its own byte length and
|
|
182
|
+
// a cut re-measures the remainder. Bounded by `maxFrameBytes`.
|
|
183
|
+
let bufferedBytes = 0;
|
|
184
|
+
let lastSeq;
|
|
185
|
+
try {
|
|
186
|
+
for (;;) {
|
|
187
|
+
const { value, done } = await reader.read();
|
|
188
|
+
if (done)
|
|
189
|
+
break;
|
|
190
|
+
buffer += decoder.decode(value, { stream: true });
|
|
191
|
+
bufferedBytes += value.byteLength;
|
|
192
|
+
// SSE frames are separated by a blank line. Handle CRLF and LF.
|
|
193
|
+
let sepIndex = nextFrameBoundary(buffer);
|
|
194
|
+
if (sepIndex.index === -1 && bufferedBytes > maxFrameBytes) {
|
|
195
|
+
throw new StreamProtocolError("frame_too_large", {
|
|
196
|
+
lastSeq,
|
|
197
|
+
bytes: bufferedBytes,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
while (sepIndex.index !== -1) {
|
|
201
|
+
const rawFrame = buffer.slice(0, sepIndex.index);
|
|
202
|
+
// A complete frame is held to the same ceiling as an incomplete one,
|
|
203
|
+
// before it is parsed, advances the cursor, or is yielded: a chunk
|
|
204
|
+
// carrying an oversized frame and its delimiter together never reaches
|
|
205
|
+
// the no-delimiter check above (#16644). `lastSeq` stays the last
|
|
206
|
+
// delivered event.
|
|
207
|
+
const frameBytes = encoder.encode(rawFrame).byteLength;
|
|
208
|
+
if (frameBytes > maxFrameBytes) {
|
|
209
|
+
throw new StreamProtocolError("frame_too_large", {
|
|
210
|
+
lastSeq,
|
|
211
|
+
bytes: frameBytes,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
buffer = buffer.slice(sepIndex.index + sepIndex.length);
|
|
215
|
+
const parsed = parseSseFrame(rawFrame);
|
|
216
|
+
if (parsed) {
|
|
217
|
+
const frame = decodeStreamFrame(parsed, lastSeq);
|
|
218
|
+
if (frame.kind === "resync") {
|
|
219
|
+
options.onResync?.(frame.marker);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
lastSeq = frame.seq;
|
|
223
|
+
if (options.cursor)
|
|
224
|
+
options.cursor.seq = frame.seq;
|
|
225
|
+
}
|
|
226
|
+
yield frame;
|
|
227
|
+
}
|
|
228
|
+
sepIndex = nextFrameBoundary(buffer);
|
|
229
|
+
}
|
|
230
|
+
bufferedBytes = buffer === "" ? 0 : encoder.encode(buffer).byteLength;
|
|
231
|
+
if (bufferedBytes > maxFrameBytes) {
|
|
232
|
+
throw new StreamProtocolError("frame_too_large", {
|
|
233
|
+
lastSeq,
|
|
234
|
+
bytes: bufferedBytes,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
// `releaseLock()` detaches the reader but leaves the body -- and the
|
|
241
|
+
// underlying HTTP connection -- open. Any early exit from the consumer
|
|
242
|
+
// loop (`break`, `return`, or a throw from a handler) runs this block via
|
|
243
|
+
// the generator's `return()`, so without `cancel()` every early exit leaks
|
|
244
|
+
// one open connection to the SSE endpoint. The documented reconnect loop
|
|
245
|
+
// makes that one leak per reconnect, until the process runs out of sockets
|
|
246
|
+
// or the server's per-user SSE lease cap rejects the user's own
|
|
247
|
+
// reconnects. `cancel()` releases the lock as part of cancelling, and can
|
|
248
|
+
// reject on an already-errored stream, so the rejection is swallowed to
|
|
249
|
+
// keep the `finally` non-throwing (#9682).
|
|
250
|
+
await reader.cancel().catch(() => undefined);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Default for `ResilientStreamOptions.maxReconnects`. */
|
|
254
|
+
export const DEFAULT_MAX_STREAM_RECONNECTS = 10;
|
|
255
|
+
/** Default for `ResilientStreamOptions.maxRetryAfterMs`. */
|
|
256
|
+
export const DEFAULT_MAX_STREAM_RETRY_AFTER_MS = RETRY_AFTER_CEILING_MS;
|
|
257
|
+
const STREAM_RECONNECT_BASE_MS = 1_000;
|
|
258
|
+
const STREAM_RECONNECT_MAX_MS = 30_000;
|
|
259
|
+
const STREAM_RECONNECT_JITTER_MS = 250;
|
|
260
|
+
/**
|
|
261
|
+
* `streamFeedResilient` gave up after `maxReconnects` consecutive failed
|
|
262
|
+
* connections. `lastSeq` is the seq to resume after (pass it as `lastEventId`
|
|
263
|
+
* later); `cause` is the last connection's failure.
|
|
264
|
+
*/
|
|
265
|
+
export class StreamReconnectsExhaustedError extends Error {
|
|
266
|
+
lastSeq;
|
|
267
|
+
attempts;
|
|
268
|
+
constructor(attempts, lastSeq, cause) {
|
|
269
|
+
super(`0xinsider stream gave up after ${String(attempts)} consecutive reconnect attempts`, { cause });
|
|
270
|
+
this.name = "StreamReconnectsExhaustedError";
|
|
271
|
+
this.attempts = attempts;
|
|
272
|
+
this.lastSeq = lastSeq;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* `streamFeedResilient` received a server-requested wait longer than
|
|
277
|
+
* `maxRetryAfterMs`, so the wait is yours to schedule (#16249). `retryAt` is
|
|
278
|
+
* the server's not-before instant as this client read it (the header's
|
|
279
|
+
* seconds added to the local clock, the header's HTTP-date, or terminal
|
|
280
|
+
* `retry_at` when no usable header exists); `cause` is
|
|
281
|
+
* the refusal, whose `retryAt` (from the body's `retry_at`) is the server's
|
|
282
|
+
* own clock reading of the same instant. `lastSeq` is the seq to resume after:
|
|
283
|
+
* pass it as `lastEventId` when you reconnect at `retryAt`. `attempts` is how
|
|
284
|
+
* many consecutive failed connections preceded this one; the refusal itself
|
|
285
|
+
* spent none of `maxReconnects`.
|
|
286
|
+
*
|
|
287
|
+
* Nothing here was clamped: a wait the timer cannot hold is never shortened
|
|
288
|
+
* into an earlier reconnect.
|
|
289
|
+
*/
|
|
290
|
+
export class StreamRetryDeferredError extends Error {
|
|
291
|
+
retryAt;
|
|
292
|
+
retryAfterMs;
|
|
293
|
+
lastSeq;
|
|
294
|
+
attempts;
|
|
295
|
+
constructor(retryAfterMs, lastSeq, attempts, cause) {
|
|
296
|
+
const retryAt = new Date(Date.now() + retryAfterMs);
|
|
297
|
+
super(`0xinsider stream requested a retry after ${String(Math.round(retryAfterMs / 1000))} s, past the in-process ceiling; reconnect after ${retryAt.toISOString()}${lastSeq === undefined ? "" : ` with lastEventId ${String(lastSeq)}`}`, { cause });
|
|
298
|
+
this.name = "StreamRetryDeferredError";
|
|
299
|
+
this.retryAt = retryAt;
|
|
300
|
+
this.retryAfterMs = retryAfterMs;
|
|
301
|
+
this.lastSeq = lastSeq;
|
|
302
|
+
this.attempts = attempts;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* A 4xx other than 429 will fail the same way on every reconnect: a bad filter
|
|
307
|
+
* (400), a key that is invalid (401), lapsed (402), refused (403), or locked
|
|
308
|
+
* (423). Reconnecting would only hammer the API with a credential a person
|
|
309
|
+
* has to fix.
|
|
310
|
+
*/
|
|
311
|
+
function isPermanentStreamError(error) {
|
|
312
|
+
// A protocol error is permanent too (#16248): reconnecting from `lastSeq`
|
|
313
|
+
// replays the same frame while the server retains it, and a wrong media
|
|
314
|
+
// type is the same answer on every connection.
|
|
315
|
+
if (error instanceof StreamProtocolError)
|
|
316
|
+
return true;
|
|
317
|
+
// A terminal frame that said `retry: false` (#16546).
|
|
318
|
+
if (error instanceof OxinsiderApiError && permanentTerminalErrors.has(error)) {
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
return (error instanceof OxinsiderApiError &&
|
|
322
|
+
error.status >= 400 &&
|
|
323
|
+
error.status < 500 &&
|
|
324
|
+
error.status !== 429);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The wait the server asked for, in ms, or `null` when the refusal carried
|
|
328
|
+
* no usable `Retry-After` or `retry_at`. Prefer the already parsed HTTP
|
|
329
|
+
* duration; terminal frames have no new headers, so their absolute instant
|
|
330
|
+
* is measured once against the local clock. Clock alignment is the caller's
|
|
331
|
+
* responsibility. A past instant means zero wait; invalid guidance is absent.
|
|
332
|
+
*/
|
|
333
|
+
function serverRequestedWaitMs(cause, nowMs = Date.now()) {
|
|
334
|
+
if (!(cause instanceof OxinsiderApiError))
|
|
335
|
+
return null;
|
|
336
|
+
const seconds = cause.retryAfterSeconds;
|
|
337
|
+
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0) {
|
|
338
|
+
return seconds * 1000;
|
|
339
|
+
}
|
|
340
|
+
const retryAtMs = cause.retryAt?.getTime();
|
|
341
|
+
return retryAtMs !== undefined && Number.isFinite(retryAtMs)
|
|
342
|
+
? Math.max(0, retryAtMs - nowMs)
|
|
343
|
+
: null;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Milliseconds before reconnect `attempt` (1-based) when the server did not
|
|
347
|
+
* say: a jittered exponential backoff from 1 s capped at 30 s. A server-
|
|
348
|
+
* requested wait is handled by the loop, which checks it against
|
|
349
|
+
* `maxRetryAfterMs` before adding jitter.
|
|
350
|
+
*/
|
|
351
|
+
function streamBackoffMs(attempt) {
|
|
352
|
+
const ceiling = Math.min(STREAM_RECONNECT_MAX_MS, STREAM_RECONNECT_BASE_MS * 2 ** (attempt - 1));
|
|
353
|
+
return ceiling / 2 + Math.random() * (ceiling / 2);
|
|
354
|
+
}
|
|
355
|
+
function assertMaxRetryAfterMs(value) {
|
|
356
|
+
if (Number.isNaN(value) || value < 0) {
|
|
357
|
+
throw new Error(`maxRetryAfterMs must be a non-negative number of milliseconds or Infinity, got ${String(value)}`);
|
|
358
|
+
}
|
|
359
|
+
return value;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* `streamFeed` with the reconnect-and-resume loop built in (#14286).
|
|
363
|
+
*
|
|
364
|
+
* - A clean server close, a network error, a 429 or a 5xx reconnects,
|
|
365
|
+
* resuming after the last delivered seq (`Last-Event-ID`). Before any frame
|
|
366
|
+
* arrives it resumes from `lastEventId`, or attaches live when that is unset.
|
|
367
|
+
* - The wait is `Retry-After` (delta-seconds or an HTTP-date) plus jitter when
|
|
368
|
+
* the refusal carries one, otherwise a jittered backoff from 1 s capped at
|
|
369
|
+
* 30 s. A `Retry-After` past `maxRetryAfterMs` (60 s by default) is not
|
|
370
|
+
* waited out: the loop throws `StreamRetryDeferredError` with `retryAt` and
|
|
371
|
+
* `lastSeq`, spending no reconnect, and never clamps the wait to an earlier
|
|
372
|
+
* one (#16249).
|
|
373
|
+
* - Any other 4xx (400, 401, 402, 403, 423) is thrown at once: it cannot heal
|
|
374
|
+
* on its own. So is `StreamProtocolError` (#16248): a resume from
|
|
375
|
+
* `lastSeq` would replay the malformed frame, so the decision is yours.
|
|
376
|
+
* - After `maxReconnects` consecutive connections that delivered nothing, it
|
|
377
|
+
* throws `StreamReconnectsExhaustedError` carrying `lastSeq`.
|
|
378
|
+
* - `resync` markers are yielded unchanged, and `onResync` still fires. The
|
|
379
|
+
* marker's `id` becomes the resume cursor, as the server intends, so a
|
|
380
|
+
* reconnect after one does not request the aged window again.
|
|
381
|
+
* - Aborting `signal` ends the iterator without throwing, during a connection
|
|
382
|
+
* or a backoff. Each connection's body reader is cancelled when it ends, so
|
|
383
|
+
* reconnects do not accumulate sockets.
|
|
384
|
+
* - No request deadline applies, as with `streamFeed`.
|
|
385
|
+
*
|
|
386
|
+
* `options.cursor`, when passed, tracks the last delivered seq across every
|
|
387
|
+
* connection -- delivered, not processed (#16247). This loop resumes from it,
|
|
388
|
+
* so a consumer whose handler can fail should drive the stream with
|
|
389
|
+
* `consumeStreamCheckpointed` instead, which resumes from an acknowledged
|
|
390
|
+
* checkpoint and replays the event it failed on.
|
|
391
|
+
*
|
|
392
|
+
* @example
|
|
393
|
+
* const controller = new AbortController();
|
|
394
|
+
* for await (const frame of streamFeedResilient(client, {
|
|
395
|
+
* event: ["WhaleTradesInserted"],
|
|
396
|
+
* signal: controller.signal,
|
|
397
|
+
* onReconnect: (attempt, lastSeq, cause) => console.warn("reconnecting", attempt, lastSeq, cause),
|
|
398
|
+
* })) {
|
|
399
|
+
* if (frame.kind === "resync") await refetchState();
|
|
400
|
+
* else handle(frame.envelope);
|
|
401
|
+
* }
|
|
402
|
+
*/
|
|
403
|
+
export async function* streamFeedResilient(client, options = {}) {
|
|
404
|
+
const { maxReconnects = DEFAULT_MAX_STREAM_RECONNECTS, maxRetryAfterMs = DEFAULT_MAX_STREAM_RETRY_AFTER_MS, onReconnect, ...streamOptions } = options;
|
|
405
|
+
if (!Number.isInteger(maxReconnects) || maxReconnects < 0) {
|
|
406
|
+
throw new Error(`maxReconnects must be a non-negative integer, got ${String(maxReconnects)}`);
|
|
407
|
+
}
|
|
408
|
+
const retryAfterCeilingMs = assertMaxRetryAfterMs(maxRetryAfterMs);
|
|
409
|
+
// The one configuration error `streamFeed` throws before any request; it
|
|
410
|
+
// would otherwise count as a transport failure and burn every reconnect.
|
|
411
|
+
if (!client.getApiKey() && !client.isSandbox()) {
|
|
412
|
+
throw new Error("streamFeedResilient requires an API key (oxi_sk_*)");
|
|
413
|
+
}
|
|
414
|
+
const signal = streamOptions.signal;
|
|
415
|
+
const cursor = streamOptions.cursor ?? {};
|
|
416
|
+
const initialLastEventId = streamOptions.lastEventId;
|
|
417
|
+
let attempt = 0;
|
|
418
|
+
for (;;) {
|
|
419
|
+
if (signal?.aborted)
|
|
420
|
+
return;
|
|
421
|
+
let cause;
|
|
422
|
+
try {
|
|
423
|
+
for await (const frame of streamFeed(client, {
|
|
424
|
+
...streamOptions,
|
|
425
|
+
cursor,
|
|
426
|
+
lastEventId: cursor.seq ?? initialLastEventId,
|
|
427
|
+
})) {
|
|
428
|
+
attempt = 0;
|
|
429
|
+
if (frame.kind === "resync" && frame.seq !== null) {
|
|
430
|
+
cursor.seq = frame.seq;
|
|
431
|
+
}
|
|
432
|
+
yield frame;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
if (signal?.aborted)
|
|
437
|
+
return;
|
|
438
|
+
if (isPermanentStreamError(error))
|
|
439
|
+
throw error;
|
|
440
|
+
cause = error;
|
|
441
|
+
}
|
|
442
|
+
if (signal?.aborted)
|
|
443
|
+
return;
|
|
444
|
+
const lastSeq = cursor.seq ?? toSeq(initialLastEventId);
|
|
445
|
+
// A server-requested wait past the ceiling is deferred to the caller
|
|
446
|
+
// BEFORE it counts as a reconnect: the server said when, and burning the
|
|
447
|
+
// short reconnect budget on a wait we will not hold would be spending it
|
|
448
|
+
// on nothing.
|
|
449
|
+
const requestedMs = serverRequestedWaitMs(cause);
|
|
450
|
+
if (requestedMs !== null && requestedMs > retryAfterCeilingMs) {
|
|
451
|
+
throw new StreamRetryDeferredError(requestedMs, lastSeq, attempt, cause);
|
|
452
|
+
}
|
|
453
|
+
attempt += 1;
|
|
454
|
+
if (attempt > maxReconnects) {
|
|
455
|
+
throw new StreamReconnectsExhaustedError(attempt - 1, lastSeq, cause);
|
|
456
|
+
}
|
|
457
|
+
onReconnect?.(attempt, lastSeq, cause);
|
|
458
|
+
const delayMs = requestedMs === null
|
|
459
|
+
? streamBackoffMs(attempt)
|
|
460
|
+
: requestedMs + Math.random() * STREAM_RECONNECT_JITTER_MS;
|
|
461
|
+
if (!(await waitUnlessAborted(delayMs, signal))) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function toSeq(value) {
|
|
467
|
+
if (value === undefined)
|
|
468
|
+
return undefined;
|
|
469
|
+
const seq = Number(value);
|
|
470
|
+
return Number.isFinite(seq) ? seq : undefined;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Callback-style stream consumer for environments where an async iterator is
|
|
474
|
+
* awkward. Returns a promise that resolves when the stream ends (or rejects on
|
|
475
|
+
* error / abort). Pass a `signal` to stop it.
|
|
476
|
+
*
|
|
477
|
+
* Delivery, not processing (#16247): `options.cursor` is written by
|
|
478
|
+
* `streamFeed` BEFORE the frame is yielded, so it has already moved past an
|
|
479
|
+
* event whose `onEvent` then throws. Reconnecting from it skips that event.
|
|
480
|
+
* For a consumer that must not lose work, use `consumeStreamCheckpointed`,
|
|
481
|
+
* which advances an acknowledged checkpoint only after the handler and your
|
|
482
|
+
* own durable write have both resolved.
|
|
483
|
+
*/
|
|
484
|
+
export async function consumeStream(client, handlers, options = {}) {
|
|
485
|
+
for await (const frame of streamFeed(client, options)) {
|
|
486
|
+
if (frame.kind === "event") {
|
|
487
|
+
await handlers.onEvent?.(frame.envelope, frame.seq);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
await handlers.onResync?.(frame.marker, frame.seq);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* `consumeStreamCheckpointed` gave up after `maxHandlerRetries` consecutive
|
|
496
|
+
* failures at the same sequence (#16247). `checkpoint` is the acknowledged
|
|
497
|
+
* sequence, still behind the failing one, and `replayFrom` is the point a
|
|
498
|
+
* later resume should pass as `lastEventId` to deliver the failed frame
|
|
499
|
+
* again while the server retains it. `cause` is the last rejection your
|
|
500
|
+
* handler produced.
|
|
501
|
+
*/
|
|
502
|
+
export class StreamHandlerFailedError extends Error {
|
|
503
|
+
seq;
|
|
504
|
+
stage;
|
|
505
|
+
attempts;
|
|
506
|
+
checkpoint;
|
|
507
|
+
replayFrom;
|
|
508
|
+
constructor(detail, cause) {
|
|
509
|
+
super(`0xinsider stream handler failed ${String(detail.attempts)} time(s) at ${detail.seq === undefined ? "a resync marker" : `seq ${String(detail.seq)}`} (${detail.stage}); the acknowledged checkpoint is ${detail.checkpoint === undefined ? "unset" : String(detail.checkpoint)}`, { cause });
|
|
510
|
+
this.name = "StreamHandlerFailedError";
|
|
511
|
+
this.seq = detail.seq;
|
|
512
|
+
this.stage = detail.stage;
|
|
513
|
+
this.attempts = detail.attempts;
|
|
514
|
+
this.checkpoint = detail.checkpoint;
|
|
515
|
+
this.replayFrom = detail.replayFrom;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/** Default for `CheckpointedStreamOptions.maxHandlerRetries`. */
|
|
519
|
+
export const DEFAULT_MAX_HANDLER_RETRIES = 3;
|
|
520
|
+
/**
|
|
521
|
+
* The resume point that replays `seq` itself when nothing has been
|
|
522
|
+
* acknowledged yet.
|
|
523
|
+
*
|
|
524
|
+
* `Last-Event-ID` is an EXCLUSIVE lower bound on the server: the replay
|
|
525
|
+
* window is `latest - requested` entries taken from the tail of the retained
|
|
526
|
+
* history, so a resume at `S - 1` delivers `S` onward and nothing older
|
|
527
|
+
* (`shared_feed_history_since`, `backend/crates/app-core/src/feed_publisher.rs`;
|
|
528
|
+
* `backend/src/api_v1/handlers/stream.rs` builds the plan from it, read
|
|
529
|
+
* 2026-09-22). Sequences are a cluster-wide integer counter, so nothing sits
|
|
530
|
+
* between `S - 1` and `S`. A non-integer or non-positive sequence gets no
|
|
531
|
+
* step-back: there is no id we can name that is certainly below it.
|
|
532
|
+
*/
|
|
533
|
+
function replayPointBefore(seq) {
|
|
534
|
+
return seq !== undefined && Number.isInteger(seq) && seq >= 1
|
|
535
|
+
? seq - 1
|
|
536
|
+
: undefined;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* The stream consumer for work that must not be silently dropped (#16247):
|
|
540
|
+
* it separates the received cursor from an acknowledged processing
|
|
541
|
+
* checkpoint, and only the checkpoint decides where a reconnect resumes.
|
|
542
|
+
*
|
|
543
|
+
* Ordering, per frame, for `kind: "event"`:
|
|
544
|
+
* 1. `onEvent` is awaited. Nothing is read from the connection while it
|
|
545
|
+
* runs, so a slow handler is backpressure on the socket, never a queue:
|
|
546
|
+
* at most one frame is ever in flight and nothing is buffered on your
|
|
547
|
+
* behalf.
|
|
548
|
+
* 2. `onCheckpoint(seq, "event")` is awaited -- your durable write.
|
|
549
|
+
* 3. Only then does `checkpoint.seq` become `seq`, and only then does the
|
|
550
|
+
* next reconnect resume after it.
|
|
551
|
+
* A rejection at step 1 or 2 leaves the checkpoint BEFORE the event: the
|
|
552
|
+
* connection is closed, a jittered backoff runs, and the consumer reconnects
|
|
553
|
+
* from the checkpoint, which replays the unacknowledged event while the
|
|
554
|
+
* server retains it. `onHandlerError` reports each failure with its attempt
|
|
555
|
+
* number, the replay point and whether another attempt follows. After
|
|
556
|
+
* `maxHandlerRetries` consecutive failures at the same sequence it throws
|
|
557
|
+
* `StreamHandlerFailedError`, carrying the unadvanced checkpoint.
|
|
558
|
+
*
|
|
559
|
+
* A `resync` marker is the same barrier: `onResync` is awaited, then
|
|
560
|
+
* `onCheckpoint(seq, "resync")`, and an interrupted refresh commits nothing,
|
|
561
|
+
* so the recovery is retried instead of being recorded as done. (The
|
|
562
|
+
* fire-and-forget `StreamOptions.onResync` notification is not awaited and is
|
|
563
|
+
* not a barrier -- do not use it as one.)
|
|
564
|
+
*
|
|
565
|
+
* Delivery is AT-LEAST-ONCE. A replay re-delivers every unacknowledged frame,
|
|
566
|
+
* and a handler that succeeded but whose `onCheckpoint` write failed sees its
|
|
567
|
+
* event again. Deduplicate on `seq` (it is monotonic per cluster) or make the
|
|
568
|
+
* side effect idempotent; nothing here can promise exactly-once side effects.
|
|
569
|
+
*
|
|
570
|
+
* `options.cursor` is untouched in meaning: it still tracks the last
|
|
571
|
+
* DELIVERED seq, including frames whose handler later failed, and it rewinds
|
|
572
|
+
* when a replay re-delivers them. Read it for transport progress; never as
|
|
573
|
+
* proof that the work was done.
|
|
574
|
+
*
|
|
575
|
+
* Transport recovery, `Retry-After` handling, permanent 4xx and
|
|
576
|
+
* `StreamProtocolError` behave exactly as in `streamFeedResilient`, which
|
|
577
|
+
* this drives. `StreamReconnectsExhaustedError` and
|
|
578
|
+
* `StreamRetryDeferredError` surface unchanged; their `lastSeq` is the
|
|
579
|
+
* acknowledged checkpoint, because a reconnect can only happen between
|
|
580
|
+
* handlers. Aborting `signal` ends the consumer without throwing: a handler
|
|
581
|
+
* already running is awaited (it is not cancelled for you -- pass the same
|
|
582
|
+
* signal into your own work if you want that), and if it succeeds its
|
|
583
|
+
* checkpoint is committed before the consumer returns. If an awaited event,
|
|
584
|
+
* resync, or checkpoint callback rejects during cancellation, the checkpoint
|
|
585
|
+
* stays unchanged and `onHandlerError` reports the original rejection with
|
|
586
|
+
* `willRetry: false` and `cancelled: true`; the consumer returns even when
|
|
587
|
+
* handler retries are disabled or exhausted. A coincident unrelated failure
|
|
588
|
+
* is still reported, without claiming cancellation caused it.
|
|
589
|
+
*
|
|
590
|
+
* @example
|
|
591
|
+
* const checkpoint = { seq: await loadCheckpoint() };
|
|
592
|
+
* await consumeStreamCheckpointed(client, {
|
|
593
|
+
* onEvent: async (envelope, seq) => { await applyOnce(seq, envelope); },
|
|
594
|
+
* onResync: async () => { await refetchCurrentState(); },
|
|
595
|
+
* onCheckpoint: async (seq) => { await saveCheckpoint(seq); },
|
|
596
|
+
* }, {
|
|
597
|
+
* event: ["WhaleTradesInserted"],
|
|
598
|
+
* lastEventId: checkpoint.seq,
|
|
599
|
+
* checkpoint,
|
|
600
|
+
* signal: controller.signal,
|
|
601
|
+
* onHandlerError: (error, failure) =>
|
|
602
|
+
* console.warn("handler failed", failure.seq, failure.attempt, failure.willRetry, error),
|
|
603
|
+
* });
|
|
604
|
+
*/
|
|
605
|
+
export async function consumeStreamCheckpointed(client, handlers, options = {}) {
|
|
606
|
+
const { checkpoint = {}, maxHandlerRetries = DEFAULT_MAX_HANDLER_RETRIES, onHandlerError, ...streamOptions } = options;
|
|
607
|
+
if (!Number.isInteger(maxHandlerRetries) || maxHandlerRetries < 0) {
|
|
608
|
+
throw new Error(`maxHandlerRetries must be a non-negative integer, got ${String(maxHandlerRetries)}`);
|
|
609
|
+
}
|
|
610
|
+
const signal = streamOptions.signal;
|
|
611
|
+
// The caller's received cursor, if they passed one. It keeps its delivery
|
|
612
|
+
// meaning: this consumer mirrors every frame into it and never reads it
|
|
613
|
+
// back as a resume point.
|
|
614
|
+
const received = streamOptions.cursor;
|
|
615
|
+
const initialLastEventId = toSeq(streamOptions.lastEventId);
|
|
616
|
+
// Where the next connection resumes. It starts at the acknowledged
|
|
617
|
+
// checkpoint (or the caller's `lastEventId`) and only ever moves to a
|
|
618
|
+
// sequence whose processing completed.
|
|
619
|
+
let resumeAfter = checkpoint.seq ?? initialLastEventId;
|
|
620
|
+
let failingSeq;
|
|
621
|
+
let failures = 0;
|
|
622
|
+
for (;;) {
|
|
623
|
+
if (signal?.aborted)
|
|
624
|
+
return;
|
|
625
|
+
let pending;
|
|
626
|
+
// A cursor private to this connection: `streamFeedResilient` reads it
|
|
627
|
+
// back as its own resume point across transport reconnects, so the
|
|
628
|
+
// caller's received cursor must never be handed to it -- that cursor has
|
|
629
|
+
// already moved past an unacknowledged event, and lending it would make
|
|
630
|
+
// the replay resume after the frame it exists to redeliver.
|
|
631
|
+
const delivered = { seq: resumeAfter };
|
|
632
|
+
for await (const frame of streamFeedResilient(client, {
|
|
633
|
+
...streamOptions,
|
|
634
|
+
cursor: delivered,
|
|
635
|
+
lastEventId: resumeAfter,
|
|
636
|
+
})) {
|
|
637
|
+
if (received && delivered.seq !== undefined)
|
|
638
|
+
received.seq = delivered.seq;
|
|
639
|
+
if (frame.kind === "event") {
|
|
640
|
+
try {
|
|
641
|
+
await handlers.onEvent(frame.envelope, frame.seq);
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
pending = { error, stage: "event", seq: frame.seq };
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
await handlers.onCheckpoint?.(frame.seq, "event");
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
pending = { error, stage: "checkpoint", seq: frame.seq };
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
checkpoint.seq = frame.seq;
|
|
655
|
+
resumeAfter = frame.seq;
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
try {
|
|
659
|
+
await handlers.onResync?.(frame.marker, frame.seq);
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
pending = { error, stage: "resync", seq: frame.seq ?? undefined };
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
if (frame.seq !== null) {
|
|
666
|
+
try {
|
|
667
|
+
await handlers.onCheckpoint?.(frame.seq, "resync");
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
pending = { error, stage: "checkpoint", seq: frame.seq };
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
checkpoint.seq = frame.seq;
|
|
674
|
+
resumeAfter = frame.seq;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
failures = 0;
|
|
678
|
+
failingSeq = undefined;
|
|
679
|
+
if (signal?.aborted)
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// `streamFeedResilient` only ends without throwing when the signal was
|
|
683
|
+
// aborted, so a loop that ends with nothing pending is a clean stop.
|
|
684
|
+
if (!pending)
|
|
685
|
+
return;
|
|
686
|
+
const failedSeq = pending.seq;
|
|
687
|
+
const nextFailures = failures > 0 && failedSeq === failingSeq ? failures + 1 : 1;
|
|
688
|
+
resumeAfter =
|
|
689
|
+
checkpoint.seq ?? initialLastEventId ?? replayPointBefore(failedSeq);
|
|
690
|
+
// Iterator cleanup and the awaited callback have finished. Observe caller
|
|
691
|
+
// cancellation before committing retry accounting, but retain the failure
|
|
692
|
+
// diagnostic: an unrelated rejection can race with the same signal.
|
|
693
|
+
const cancelled = signal?.aborted === true;
|
|
694
|
+
const willRetry = !cancelled && nextFailures <= maxHandlerRetries;
|
|
695
|
+
onHandlerError?.(pending.error, {
|
|
696
|
+
seq: failedSeq,
|
|
697
|
+
stage: pending.stage,
|
|
698
|
+
attempt: nextFailures,
|
|
699
|
+
willRetry,
|
|
700
|
+
...(cancelled ? { cancelled: true } : {}),
|
|
701
|
+
replayFrom: resumeAfter,
|
|
702
|
+
checkpoint: checkpoint.seq,
|
|
703
|
+
});
|
|
704
|
+
// The observer can also stop the consumer synchronously.
|
|
705
|
+
if (cancelled || signal?.aborted)
|
|
706
|
+
return;
|
|
707
|
+
failingSeq = failedSeq;
|
|
708
|
+
failures = nextFailures;
|
|
709
|
+
if (!willRetry) {
|
|
710
|
+
throw new StreamHandlerFailedError({
|
|
711
|
+
seq: failedSeq,
|
|
712
|
+
stage: pending.stage,
|
|
713
|
+
attempts: failures,
|
|
714
|
+
checkpoint: checkpoint.seq,
|
|
715
|
+
replayFrom: resumeAfter,
|
|
716
|
+
}, pending.error);
|
|
717
|
+
}
|
|
718
|
+
if (!(await waitUnlessAborted(streamBackoffMs(failures), signal))) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
function buildStreamUrl(client, options) {
|
|
724
|
+
// Through the same resolver as `buildUrl`, so a path-bearing base such as
|
|
725
|
+
// the sandbox's `/sandbox` lands in the same place for REST and SSE.
|
|
726
|
+
const url = resolveApiUrl(client.getBaseUrl(), "/api/v1/stream");
|
|
727
|
+
if (options.event && options.event.length > 0) {
|
|
728
|
+
url.searchParams.set("event", options.event.join(","));
|
|
729
|
+
}
|
|
730
|
+
if (options.condition_id) {
|
|
731
|
+
url.searchParams.set("condition_id", options.condition_id);
|
|
732
|
+
}
|
|
733
|
+
if (options.min_grade) {
|
|
734
|
+
url.searchParams.set("min_grade", options.min_grade);
|
|
735
|
+
}
|
|
736
|
+
return url.toString();
|
|
737
|
+
}
|
|
738
|
+
/** Parse one SSE frame (without the trailing blank line). */
|
|
739
|
+
export function parseSseFrame(raw) {
|
|
740
|
+
const lines = raw.split(/\r?\n/);
|
|
741
|
+
let event;
|
|
742
|
+
let id;
|
|
743
|
+
const dataParts = [];
|
|
744
|
+
let sawField = false;
|
|
745
|
+
for (const line of lines) {
|
|
746
|
+
if (line === "" || line.startsWith(":")) {
|
|
747
|
+
// Blank or comment line (e.g. ": keep-alive"): ignore.
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const colon = line.indexOf(":");
|
|
751
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
752
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
753
|
+
if (value.startsWith(" "))
|
|
754
|
+
value = value.slice(1);
|
|
755
|
+
switch (field) {
|
|
756
|
+
case "event":
|
|
757
|
+
event = value;
|
|
758
|
+
sawField = true;
|
|
759
|
+
break;
|
|
760
|
+
case "id": {
|
|
761
|
+
const n = Number(value);
|
|
762
|
+
if (Number.isFinite(n))
|
|
763
|
+
id = n;
|
|
764
|
+
sawField = true;
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
case "data":
|
|
768
|
+
dataParts.push(value);
|
|
769
|
+
sawField = true;
|
|
770
|
+
break;
|
|
771
|
+
default:
|
|
772
|
+
// Unknown SSE field; ignore per the spec.
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (!sawField && dataParts.length === 0) {
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
return { event, id, data: dataParts.join("\n") };
|
|
780
|
+
}
|
|
781
|
+
/** Find the next SSE frame boundary (blank line); handles `\n\n` and `\r\n\r\n`. */
|
|
782
|
+
function nextFrameBoundary(buffer) {
|
|
783
|
+
// Three terminator shapes, not two (#9682). A producer that ends FIELD lines
|
|
784
|
+
// with CRLF but the blank separator line with a bare LF yields "\r\n\n",
|
|
785
|
+
// which the old two-way scan cut at the "\n\n" -- one byte late, leaving a
|
|
786
|
+
// trailing "\r" on the last field. `parseSseFrame` splits on /\r?\n/ and so
|
|
787
|
+
// cannot strip a "\r" from a line with no newline after it, which turned
|
|
788
|
+
// `event: resync` into `event: "resync\r"` and made the exact `=== "resync"`
|
|
789
|
+
// routing check fail: the resume marker the cursor protocol depends on was
|
|
790
|
+
// silently delivered as a malformed envelope. The current Axum backend emits
|
|
791
|
+
// LF only, so this hardens a latent path rather than fixing a live break.
|
|
792
|
+
const candidates = [
|
|
793
|
+
{ index: buffer.indexOf("\r\n\r\n"), length: 4 },
|
|
794
|
+
{ index: buffer.indexOf("\r\n\n"), length: 3 },
|
|
795
|
+
{ index: buffer.indexOf("\n\n"), length: 2 },
|
|
796
|
+
].filter((candidate) => candidate.index !== -1);
|
|
797
|
+
if (candidates.length === 0)
|
|
798
|
+
return { index: -1, length: 0 };
|
|
799
|
+
// Earliest boundary wins; on a tie the LONGEST terminator wins, because
|
|
800
|
+
// "\r\n\r\n" and "\r\n\n" both also match "\n\n" at a later index and a
|
|
801
|
+
// short match would leave terminator bytes in the next frame.
|
|
802
|
+
return candidates.reduce((best, candidate) => candidate.index < best.index ||
|
|
803
|
+
(candidate.index === best.index && candidate.length > best.length)
|
|
804
|
+
? candidate
|
|
805
|
+
: best);
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Turn one parsed SSE frame into a `StreamEvent`, or throw
|
|
809
|
+
* `StreamProtocolError` (#16248). `lastSeq` is only carried onto the error.
|
|
810
|
+
*/
|
|
811
|
+
export function decodeStreamFrame(parsed, lastSeq) {
|
|
812
|
+
const detail = {
|
|
813
|
+
lastSeq,
|
|
814
|
+
frameId: parsed.id,
|
|
815
|
+
event: parsed.event,
|
|
816
|
+
bytes: new TextEncoder().encode(parsed.data).byteLength,
|
|
817
|
+
};
|
|
818
|
+
let payload;
|
|
819
|
+
try {
|
|
820
|
+
payload = parsed.data === "" ? undefined : JSON.parse(parsed.data);
|
|
821
|
+
}
|
|
822
|
+
catch {
|
|
823
|
+
throw new StreamProtocolError("invalid_json", detail);
|
|
824
|
+
}
|
|
825
|
+
if (parsed.event === "error") {
|
|
826
|
+
throw terminalStreamError(payload, detail);
|
|
827
|
+
}
|
|
828
|
+
if (parsed.event === "resync") {
|
|
829
|
+
if (!isPlainObject(payload)) {
|
|
830
|
+
throw new StreamProtocolError("invalid_resync", detail);
|
|
831
|
+
}
|
|
832
|
+
if ("type" in payload && payload.type !== "resync") {
|
|
833
|
+
throw new StreamProtocolError("invalid_resync", detail);
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
kind: "resync",
|
|
837
|
+
seq: parsed.id ?? null,
|
|
838
|
+
marker: payload,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
if (payload === undefined) {
|
|
842
|
+
throw new StreamProtocolError("invalid_json", detail);
|
|
843
|
+
}
|
|
844
|
+
if (!isPlainObject(payload)) {
|
|
845
|
+
throw new StreamProtocolError("invalid_envelope", detail);
|
|
846
|
+
}
|
|
847
|
+
const seq = typeof payload.seq === "number" && Number.isFinite(payload.seq)
|
|
848
|
+
? payload.seq
|
|
849
|
+
: parsed.id;
|
|
850
|
+
if (seq === undefined || !Number.isFinite(seq)) {
|
|
851
|
+
throw new StreamProtocolError("unusable_sequence", detail);
|
|
852
|
+
}
|
|
853
|
+
return { kind: "event", seq, envelope: payload };
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* The HTTP status the reconnect is answered with, for each code a terminal
|
|
857
|
+
* `event: error` frame carries (#16546). The frame has no status of its own;
|
|
858
|
+
* this mirrors `ApiError::status_code` in `backend/crates/api-core`, so the
|
|
859
|
+
* error thrown mid-stream is the one the next request would get.
|
|
860
|
+
*/
|
|
861
|
+
const STREAM_ERROR_CODE_STATUS = {
|
|
862
|
+
bad_request: 400,
|
|
863
|
+
invalid_api_key: 401,
|
|
864
|
+
subscription_required: 402,
|
|
865
|
+
forbidden: 403,
|
|
866
|
+
insufficient_scope: 403,
|
|
867
|
+
not_found: 404,
|
|
868
|
+
request_timeout: 408,
|
|
869
|
+
account_locked: 423,
|
|
870
|
+
rate_limited: 429,
|
|
871
|
+
internal_error: 500,
|
|
872
|
+
rate_limit_unavailable: 503,
|
|
873
|
+
};
|
|
874
|
+
const STREAM_ERROR_UNAVAILABLE_REASONS = new Set([
|
|
875
|
+
"database_unavailable",
|
|
876
|
+
"read_model_warming",
|
|
877
|
+
"request_accounting_unavailable",
|
|
878
|
+
]);
|
|
879
|
+
/**
|
|
880
|
+
* Errors built from a terminal frame that said `retry: false`: permanent
|
|
881
|
+
* whatever their status, so no consumer reconnects on them (#16546).
|
|
882
|
+
*/
|
|
883
|
+
const permanentTerminalErrors = new WeakSet();
|
|
884
|
+
/**
|
|
885
|
+
* The typed error a terminal `event: error` frame stands for (#16546).
|
|
886
|
+
*
|
|
887
|
+
* The backend ends `/api/v1/stream` with `{ "type": "error", "error": {...},
|
|
888
|
+
* "retry": <bool> }` when the key is revoked, the account lapses, or the
|
|
889
|
+
* credential store cannot confirm the key. The frame is never a feed event:
|
|
890
|
+
* it is thrown, so it reaches no handler and moves no cursor or checkpoint.
|
|
891
|
+
* `retry: false` is permanent; `retry: true` goes through the resilient
|
|
892
|
+
* consumer's reconnect path like any 5xx.
|
|
893
|
+
*/
|
|
894
|
+
function terminalStreamError(payload, detail) {
|
|
895
|
+
if (!isPlainObject(payload) || !isPlainObject(payload.error)) {
|
|
896
|
+
return new StreamProtocolError("invalid_envelope", detail);
|
|
897
|
+
}
|
|
898
|
+
const body = payload.error;
|
|
899
|
+
const retry = payload.retry === true;
|
|
900
|
+
const code = typeof body.code === "string" ? body.code : undefined;
|
|
901
|
+
const reason = typeof body.reason === "string" ? body.reason : undefined;
|
|
902
|
+
const status = reason !== undefined && STREAM_ERROR_UNAVAILABLE_REASONS.has(reason)
|
|
903
|
+
? 503
|
|
904
|
+
: ((code !== undefined ? STREAM_ERROR_CODE_STATUS[code] : undefined) ??
|
|
905
|
+
(retry ? 503 : 400));
|
|
906
|
+
const error = errorFromResponse(status, { object: "error", error: body });
|
|
907
|
+
if (!retry)
|
|
908
|
+
permanentTerminalErrors.add(error);
|
|
909
|
+
return error;
|
|
910
|
+
}
|
|
911
|
+
function isPlainObject(value) {
|
|
912
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
913
|
+
}
|
|
914
|
+
function tryParse(text) {
|
|
915
|
+
if (text === null || text === "")
|
|
916
|
+
return null;
|
|
917
|
+
try {
|
|
918
|
+
return JSON.parse(text);
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
async function safeText(response) {
|
|
925
|
+
try {
|
|
926
|
+
return await response.text();
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
return "";
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
//# sourceMappingURL=stream.js.map
|