@littlebigbrain/client 0.5.2 → 0.6.1

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. */
@@ -32,6 +34,8 @@ export declare class LbbError extends Error {
32
34
  readonly docUrl?: string | null;
33
35
  readonly retryable?: boolean;
34
36
  readonly retryAfterSeconds?: number;
37
+ /** Actionable guidance for composite stack-endpoint routing errors. */
38
+ readonly endpointHint?: string;
35
39
  constructor(status: number, body: string, error?: LbbErrorPayload | undefined);
36
40
  }
37
41
  export type QueryValue = string | number | boolean | undefined;
@@ -41,6 +45,34 @@ export declare function retryableStatus(status: number): boolean;
41
45
  export declare function retryAllowed(method: string, idempotencyKey?: string): boolean;
42
46
  /** Parse a Retry-After delta-seconds or HTTP-date value, capped at one minute. */
43
47
  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;
48
+ /**
49
+ * Full-jitter exponential backoff: `uniform(0, base * 2**attempt)`, capped at
50
+ * one minute. Replaces linear backoff so many clients recovering from one
51
+ * outage do not retry in lockstep (a thundering herd that re-triggers it).
52
+ */
53
+ export declare function fullJitterBackoffMs(baseDelayMs: number, attempt: number, rng?: () => number): number;
54
+ /**
55
+ * The server's own body hint `error.retry_after_seconds` in ms (capped), or
56
+ * `undefined` when the body is naked (a bare LB 5xx) or carries no hint. Used
57
+ * as the backoff when the `Retry-After` *header* is absent.
58
+ */
59
+ export declare function retryAfterFromBodyMs(body: string): number | undefined;
60
+ /** The parsed `error.code` from an error body, or `undefined` when absent/naked. */
61
+ export declare function errorCodeFromBody(body: string): string | undefined;
62
+ /**
63
+ * True iff the server explicitly marked this error non-retryable in the body
64
+ * (`error.retryable === false`) — a durable rejection (e.g. an exhausted quota)
65
+ * the client must surface immediately instead of spending its retry budget.
66
+ */
67
+ export declare function bodyMarksTerminal(body: string): boolean;
68
+ /**
69
+ * The backoff (ms) before the next attempt: the `Retry-After` header, else the
70
+ * server's body `retry_after_seconds` hint, else full-jitter exponential
71
+ * backoff.
72
+ */
73
+ export declare function retryDelayMs(baseDelayMs: number, attempt: number, opts?: {
74
+ retryAfterHeader?: string | null;
75
+ body?: string;
76
+ }, rng?: () => number): number;
45
77
  export declare function parseResponseJson<T>(text: string, status: number, requestId?: string): T;
46
78
  export declare function parseLbbError(status: number, body: string, fallbackRequestId?: string): LbbError;
package/dist/transport.js CHANGED
@@ -10,6 +10,8 @@ export class LbbError extends Error {
10
10
  docUrl;
11
11
  retryable;
12
12
  retryAfterSeconds;
13
+ /** Actionable guidance for composite stack-endpoint routing errors. */
14
+ endpointHint;
13
15
  constructor(status, body, error) {
14
16
  super(error?.message ?? `Little Big Brain ${status}: ${body}`);
15
17
  this.status = status;
@@ -23,8 +25,18 @@ export class LbbError extends Error {
23
25
  this.docUrl = error?.doc_url;
24
26
  this.retryable = error?.retryable;
25
27
  this.retryAfterSeconds = error?.retry_after_seconds;
28
+ this.endpointHint = endpointMigrationHint(this.code);
26
29
  }
27
30
  }
31
+ function endpointMigrationHint(code) {
32
+ if (code === "stack_endpoint_required") {
33
+ return "Copy endpoint_url from the stack's Connect page and use it as baseUrl.";
34
+ }
35
+ if (code === "stack_endpoint_mismatch") {
36
+ return "Use the endpoint_url and API key from the same stack.";
37
+ }
38
+ return undefined;
39
+ }
28
40
  export function sleep(ms) {
29
41
  if (ms <= 0)
30
42
  return Promise.resolve();
@@ -62,9 +74,73 @@ export function parseRetryAfterMs(value, nowMs = Date.now()) {
62
74
  return undefined;
63
75
  return Math.min(Math.max(0, dateMs - nowMs), MAX_RETRY_AFTER_MS);
64
76
  }
65
- export function retryDelayForAttempt(baseDelayMs, attempt, retryAfter) {
66
- const fallback = Math.max(0, baseDelayMs) * (attempt + 1);
67
- return parseRetryAfterMs(retryAfter) ?? fallback;
77
+ /**
78
+ * Full-jitter exponential backoff: `uniform(0, base * 2**attempt)`, capped at
79
+ * one minute. Replaces linear backoff so many clients recovering from one
80
+ * outage do not retry in lockstep (a thundering herd that re-triggers it).
81
+ */
82
+ export function fullJitterBackoffMs(baseDelayMs, attempt, rng = Math.random) {
83
+ const ceiling = Math.min(Math.max(0, baseDelayMs) * 2 ** attempt, MAX_RETRY_AFTER_MS);
84
+ return rng() * ceiling;
85
+ }
86
+ /**
87
+ * The server's own body hint `error.retry_after_seconds` in ms (capped), or
88
+ * `undefined` when the body is naked (a bare LB 5xx) or carries no hint. Used
89
+ * as the backoff when the `Retry-After` *header* is absent.
90
+ */
91
+ export function retryAfterFromBodyMs(body) {
92
+ try {
93
+ const parsed = JSON.parse(body);
94
+ const seconds = parsed.error?.retry_after_seconds;
95
+ if (typeof seconds === "number" &&
96
+ Number.isFinite(seconds) &&
97
+ seconds >= 0) {
98
+ return Math.min(seconds * 1_000, MAX_RETRY_AFTER_MS);
99
+ }
100
+ }
101
+ catch {
102
+ // Naked LB body (no error envelope) — no hint.
103
+ }
104
+ return undefined;
105
+ }
106
+ /** The parsed `error.code` from an error body, or `undefined` when absent/naked. */
107
+ export function errorCodeFromBody(body) {
108
+ try {
109
+ const parsed = JSON.parse(body);
110
+ const code = parsed.error?.code;
111
+ return typeof code === "string" ? code : undefined;
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ }
117
+ /**
118
+ * True iff the server explicitly marked this error non-retryable in the body
119
+ * (`error.retryable === false`) — a durable rejection (e.g. an exhausted quota)
120
+ * the client must surface immediately instead of spending its retry budget.
121
+ */
122
+ export function bodyMarksTerminal(body) {
123
+ try {
124
+ const parsed = JSON.parse(body);
125
+ return parsed.error?.retryable === false;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ /**
132
+ * The backoff (ms) before the next attempt: the `Retry-After` header, else the
133
+ * server's body `retry_after_seconds` hint, else full-jitter exponential
134
+ * backoff.
135
+ */
136
+ export function retryDelayMs(baseDelayMs, attempt, opts = {}, rng = Math.random) {
137
+ const header = parseRetryAfterMs(opts.retryAfterHeader);
138
+ if (header !== undefined)
139
+ return header;
140
+ const bodyHint = opts.body !== undefined ? retryAfterFromBodyMs(opts.body) : undefined;
141
+ if (bodyHint !== undefined)
142
+ return bodyHint;
143
+ return fullJitterBackoffMs(baseDelayMs, attempt, rng);
68
144
  }
69
145
  export function parseResponseJson(text, status, requestId) {
70
146
  try {
package/dist/types.d.ts CHANGED
@@ -153,7 +153,7 @@ export declare function parseSparqlResults(response: Schemas["SparqlTextResponse
153
153
  export declare function firstPatternVariable(patterns: Schemas["AnalyticTriplePattern"][]): string;
154
154
  export declare function attributeFilter(filter: AttributeFilter, defaultVar: string): Schemas["SparqlFilter"];
155
155
  export interface LbbClientOptions {
156
- /** Base URL of the little big brain server, e.g. `https://db.eu.littlebigbrain.com`. */
156
+ /** Hosted stack endpoint, e.g. `https://7k3m9q2x--production.db.eu.littlebigbrain.com`. */
157
157
  baseUrl: string;
158
158
  /** Stack API key (`lbb_sk_test_…` / `lbb_sk_live_…`) or single-mode token. */
159
159
  apiKey?: string;
@@ -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.2",
3
+ "version": "0.6.1",
4
4
  "description": "TypeScript client for the little big brain graph + hybrid search HTTP API",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {