@littlebigbrain/client 0.5.1 → 0.6.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.
@@ -3,8 +3,10 @@ export interface CallOptions {
3
3
  idempotencyKey?: string;
4
4
  /** Override the client's per-attempt timeout. Set 0 to disable it. */
5
5
  timeoutMs?: number;
6
- /** Override the client's retry count for this request. */
6
+ /** Override the client's retry count (secondary cap) for this request. */
7
7
  maxRetries?: number;
8
+ /** Override the client's deadline-based retry budget (ms) for this request. */
9
+ retryBudgetMs?: number;
8
10
  /** Override retry safety classification. Read-only POST namespaces set this automatically. */
9
11
  retry?: boolean;
10
12
  /** Abort the request and suppress any further retries. */
@@ -41,6 +43,34 @@ export declare function retryableStatus(status: number): boolean;
41
43
  export declare function retryAllowed(method: string, idempotencyKey?: string): boolean;
42
44
  /** Parse a Retry-After delta-seconds or HTTP-date value, capped at one minute. */
43
45
  export declare function parseRetryAfterMs(value: string | null | undefined, nowMs?: number): number | undefined;
44
- export declare function retryDelayForAttempt(baseDelayMs: number, attempt: number, retryAfter?: string | null): number;
46
+ /**
47
+ * Full-jitter exponential backoff: `uniform(0, base * 2**attempt)`, capped at
48
+ * one minute. Replaces linear backoff so many clients recovering from one
49
+ * outage do not retry in lockstep (a thundering herd that re-triggers it).
50
+ */
51
+ export declare function fullJitterBackoffMs(baseDelayMs: number, attempt: number, rng?: () => number): number;
52
+ /**
53
+ * The server's own body hint `error.retry_after_seconds` in ms (capped), or
54
+ * `undefined` when the body is naked (a bare LB 5xx) or carries no hint. Used
55
+ * as the backoff when the `Retry-After` *header* is absent.
56
+ */
57
+ export declare function retryAfterFromBodyMs(body: string): number | undefined;
58
+ /** The parsed `error.code` from an error body, or `undefined` when absent/naked. */
59
+ export declare function errorCodeFromBody(body: string): string | undefined;
60
+ /**
61
+ * True iff the server explicitly marked this error non-retryable in the body
62
+ * (`error.retryable === false`) — a durable rejection (e.g. an exhausted quota)
63
+ * the client must surface immediately instead of spending its retry budget.
64
+ */
65
+ export declare function bodyMarksTerminal(body: string): boolean;
66
+ /**
67
+ * The backoff (ms) before the next attempt: the `Retry-After` header, else the
68
+ * server's body `retry_after_seconds` hint, else full-jitter exponential
69
+ * backoff.
70
+ */
71
+ export declare function retryDelayMs(baseDelayMs: number, attempt: number, opts?: {
72
+ retryAfterHeader?: string | null;
73
+ body?: string;
74
+ }, rng?: () => number): number;
45
75
  export declare function parseResponseJson<T>(text: string, status: number, requestId?: string): T;
46
76
  export declare function parseLbbError(status: number, body: string, fallbackRequestId?: string): LbbError;
package/dist/transport.js CHANGED
@@ -62,9 +62,73 @@ export function parseRetryAfterMs(value, nowMs = Date.now()) {
62
62
  return undefined;
63
63
  return Math.min(Math.max(0, dateMs - nowMs), MAX_RETRY_AFTER_MS);
64
64
  }
65
- export function retryDelayForAttempt(baseDelayMs, attempt, retryAfter) {
66
- const fallback = Math.max(0, baseDelayMs) * (attempt + 1);
67
- return parseRetryAfterMs(retryAfter) ?? fallback;
65
+ /**
66
+ * Full-jitter exponential backoff: `uniform(0, base * 2**attempt)`, capped at
67
+ * one minute. Replaces linear backoff so many clients recovering from one
68
+ * outage do not retry in lockstep (a thundering herd that re-triggers it).
69
+ */
70
+ export function fullJitterBackoffMs(baseDelayMs, attempt, rng = Math.random) {
71
+ const ceiling = Math.min(Math.max(0, baseDelayMs) * 2 ** attempt, MAX_RETRY_AFTER_MS);
72
+ return rng() * ceiling;
73
+ }
74
+ /**
75
+ * The server's own body hint `error.retry_after_seconds` in ms (capped), or
76
+ * `undefined` when the body is naked (a bare LB 5xx) or carries no hint. Used
77
+ * as the backoff when the `Retry-After` *header* is absent.
78
+ */
79
+ export function retryAfterFromBodyMs(body) {
80
+ try {
81
+ const parsed = JSON.parse(body);
82
+ const seconds = parsed.error?.retry_after_seconds;
83
+ if (typeof seconds === "number" &&
84
+ Number.isFinite(seconds) &&
85
+ seconds >= 0) {
86
+ return Math.min(seconds * 1_000, MAX_RETRY_AFTER_MS);
87
+ }
88
+ }
89
+ catch {
90
+ // Naked LB body (no error envelope) — no hint.
91
+ }
92
+ return undefined;
93
+ }
94
+ /** The parsed `error.code` from an error body, or `undefined` when absent/naked. */
95
+ export function errorCodeFromBody(body) {
96
+ try {
97
+ const parsed = JSON.parse(body);
98
+ const code = parsed.error?.code;
99
+ return typeof code === "string" ? code : undefined;
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ }
105
+ /**
106
+ * True iff the server explicitly marked this error non-retryable in the body
107
+ * (`error.retryable === false`) — a durable rejection (e.g. an exhausted quota)
108
+ * the client must surface immediately instead of spending its retry budget.
109
+ */
110
+ export function bodyMarksTerminal(body) {
111
+ try {
112
+ const parsed = JSON.parse(body);
113
+ return parsed.error?.retryable === false;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ /**
120
+ * The backoff (ms) before the next attempt: the `Retry-After` header, else the
121
+ * server's body `retry_after_seconds` hint, else full-jitter exponential
122
+ * backoff.
123
+ */
124
+ export function retryDelayMs(baseDelayMs, attempt, opts = {}, rng = Math.random) {
125
+ const header = parseRetryAfterMs(opts.retryAfterHeader);
126
+ if (header !== undefined)
127
+ return header;
128
+ const bodyHint = opts.body !== undefined ? retryAfterFromBodyMs(opts.body) : undefined;
129
+ if (bodyHint !== undefined)
130
+ return bodyHint;
131
+ return fullJitterBackoffMs(baseDelayMs, attempt, rng);
68
132
  }
69
133
  export function parseResponseJson(text, status, requestId) {
70
134
  try {
package/dist/types.d.ts CHANGED
@@ -171,16 +171,27 @@ export interface LbbClientOptions {
171
171
  fetch?: FetchLike;
172
172
  /** API version header sent on every request. Defaults to the beta reset contract. */
173
173
  apiVersion?: string;
174
- /** Retry count for 429/5xx responses and network failures. Defaults to 2. */
174
+ /**
175
+ * Secondary safety cap on retries for 429/5xx responses and network failures.
176
+ * The binding limit is `retryBudgetMs`. Defaults to 6.
177
+ */
175
178
  maxRetries?: number;
176
179
  /** Base delay between retries. Defaults to 100ms. Tests can set 0. */
177
180
  retryDelayMs?: number;
181
+ /**
182
+ * Deadline-based retry budget (ms): keep retrying a retryable request until
183
+ * this much wall-clock has elapsed, so a server's advertised `Retry-After`
184
+ * window is honored rather than truncated by `maxRetries`. Defaults to 60000.
185
+ */
186
+ retryBudgetMs?: number;
178
187
  /** Per-attempt timeout, including response-body reads. Defaults to 120 seconds; 0 disables it. */
179
188
  timeoutMs?: number;
180
189
  /** Called immediately before each network attempt. Bodies and credentials are never included. */
181
190
  onRequest?: (event: LbbRequestEvent) => void;
182
191
  /** Called once after the final HTTP response. Bodies and credentials are never included. */
183
192
  onResponse?: (event: LbbResponseEvent) => void;
193
+ /** Called before each backoff sleep, so absorbed retries are observable. Bodies and credentials are never included. */
194
+ onRetry?: (event: LbbRetryEvent) => void;
184
195
  }
185
196
  export interface LbbRequestEvent {
186
197
  method: string;
@@ -198,6 +209,20 @@ export interface LbbResponseEvent {
198
209
  retryCount: number;
199
210
  elapsedMs: number;
200
211
  }
212
+ export interface LbbRetryEvent {
213
+ method: string;
214
+ url: string;
215
+ /** 1-based number of the attempt that just failed and triggered this retry. */
216
+ attempt: number;
217
+ /** HTTP status of the failed attempt, or `undefined` for a network error. */
218
+ status?: number;
219
+ /** Parsed `error.code` of the failed attempt, when the body carried one. */
220
+ errorCode?: string;
221
+ /** The backoff (ms) about to be slept — header, body hint, or jittered backoff. */
222
+ delayMs: number;
223
+ /** Inclusive wall-clock elapsed (ms) across attempts and waits so far. */
224
+ elapsedMs: number;
225
+ }
201
226
  export type LbbStackActivityWindow = "1h" | "4h" | "12h" | "24h";
202
227
  export interface LbbStackActivityResponse {
203
228
  ok: true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebigbrain/client",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "TypeScript client for the little big brain graph + hybrid search HTTP API",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {