@allisall/sdk 0.0.0-stage → 0.1.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 +202 -0
- package/NOTICE +2 -0
- package/README.md +89 -2
- package/dist/client.d.ts +77 -0
- package/dist/client.js +130 -0
- package/dist/decimal.d.ts +20 -0
- package/dist/decimal.js +23 -0
- package/dist/errors.d.ts +79 -0
- package/dist/errors.js +98 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/ingest.d.ts +28 -0
- package/dist/ingest.js +247 -0
- package/dist/retry.d.ts +18 -0
- package/dist/retry.js +105 -0
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +60 -0
- package/dist/transport.d.ts +53 -0
- package/dist/transport.js +220 -0
- package/dist/types.d.ts +135 -0
- package/dist/types.js +80 -0
- package/dist/uuid.d.ts +6 -0
- package/dist/uuid.js +20 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +39 -4
package/dist/errors.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The base of every error the SDK throws. Catch it to handle any SDK
|
|
3
|
+
* failure; catch `ApiError`, `ConnectionError` or `IngestError` to handle
|
|
4
|
+
* one condition.
|
|
5
|
+
*/
|
|
6
|
+
export class AllisallError extends Error {
|
|
7
|
+
name = "AllisallError";
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* An error response from the API, or a successful response whose body was
|
|
11
|
+
* not the expected shape. Branch on `code`; for a code you do not
|
|
12
|
+
* recognise, fall back on `status`.
|
|
13
|
+
*/
|
|
14
|
+
export class ApiError extends AllisallError {
|
|
15
|
+
name = "ApiError";
|
|
16
|
+
/** The HTTP status. */
|
|
17
|
+
status;
|
|
18
|
+
/** The problem's `code`. Absent when the body was not a problem document. */
|
|
19
|
+
code;
|
|
20
|
+
/** The whole problem document. Absent when the body was not one. */
|
|
21
|
+
problem;
|
|
22
|
+
/** The problem's `request_id`, or else the response's `X-Request-Id`. */
|
|
23
|
+
requestId;
|
|
24
|
+
/**
|
|
25
|
+
* The response's `Retry-After`, in milliseconds from when the response
|
|
26
|
+
* arrived. Absent when the response had none, or one that did not parse
|
|
27
|
+
* or named a time in the past.
|
|
28
|
+
*/
|
|
29
|
+
retryAfter;
|
|
30
|
+
/** The response headers. */
|
|
31
|
+
headers;
|
|
32
|
+
constructor(init) {
|
|
33
|
+
super(init.message ?? apiErrorMessage(init.status, init.problem));
|
|
34
|
+
this.status = init.status;
|
|
35
|
+
this.problem = init.problem;
|
|
36
|
+
this.code = init.problem?.code;
|
|
37
|
+
const problemId = init.problem?.request_id;
|
|
38
|
+
this.requestId =
|
|
39
|
+
problemId !== undefined && problemId !== "" ? problemId : init.requestId;
|
|
40
|
+
this.retryAfter = init.retryAfter;
|
|
41
|
+
this.headers = init.headers;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function apiErrorMessage(status, problem) {
|
|
45
|
+
const parts = [`allisall: the API answered ${status}`];
|
|
46
|
+
if (problem?.code !== undefined)
|
|
47
|
+
parts.push(problem.code);
|
|
48
|
+
if (problem?.detail !== undefined)
|
|
49
|
+
parts.push(problem.detail);
|
|
50
|
+
return parts.join(": ");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A request that got no response: the connection failed, was reset, or the
|
|
54
|
+
* attempt timed out. `cause` holds the underlying error.
|
|
55
|
+
*/
|
|
56
|
+
export class ConnectionError extends AllisallError {
|
|
57
|
+
name = "ConnectionError";
|
|
58
|
+
/** The `X-Request-Id` the SDK sent on the last attempt, to quote when reporting it. */
|
|
59
|
+
requestId;
|
|
60
|
+
constructor(requestId, cause) {
|
|
61
|
+
super(`allisall: no response from the API (request ${requestId}): ${causeText(cause)}`, { cause });
|
|
62
|
+
this.requestId = requestId;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function causeText(cause) {
|
|
66
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A batch of an `ingest` call failed, so the call stopped and sent nothing
|
|
70
|
+
* after it. `cause` holds what failed the batch: an `ApiError`, a
|
|
71
|
+
* `ConnectionError`, the caller's cancellation, or the error from getting
|
|
72
|
+
* the token: the token provider's own error, unchanged, or the
|
|
73
|
+
* `AllisallError` refusing a provider's API key in a browser. To recover,
|
|
74
|
+
* send `remaining` again: every event in it keeps the `event_id` it was
|
|
75
|
+
* sent with, so any the failed batch stored come back as duplicates.
|
|
76
|
+
*/
|
|
77
|
+
export class IngestError extends AllisallError {
|
|
78
|
+
name = "IngestError";
|
|
79
|
+
/** The combined outcome of the batches that succeeded before the failed one. */
|
|
80
|
+
summary;
|
|
81
|
+
/**
|
|
82
|
+
* Copies of every event from the failed batch onward, each with its
|
|
83
|
+
* `event_id`, less any event too large to send: those are rows of
|
|
84
|
+
* `summary.errors` and never in `remaining`. To map `remaining` back to
|
|
85
|
+
* your list, walk it from `remainingFrom` and skip the `index` of every
|
|
86
|
+
* `summary.errors` row at or past `remainingFrom`. With no such row,
|
|
87
|
+
* `remaining[i]` is your event `remainingFrom + i`.
|
|
88
|
+
*/
|
|
89
|
+
remaining;
|
|
90
|
+
/** The index, in the list passed to `ingest`, of the first event in `remaining`. */
|
|
91
|
+
remainingFrom;
|
|
92
|
+
constructor(cause, summary, remaining, remainingFrom) {
|
|
93
|
+
super(`allisall: an ingest batch failed and the call stopped; send the ${remaining.length} event(s) in remaining, from index ${remainingFrom}, again: ${causeText(cause)}`, { cause });
|
|
94
|
+
this.summary = summary;
|
|
95
|
+
this.remaining = remaining;
|
|
96
|
+
this.remainingFrom = remainingFrom;
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { Allisall, type ClientOptions, type RequestOptions } from "./client.js";
|
|
2
|
+
export { type Decimal, decimal } from "./decimal.js";
|
|
3
|
+
export { AllisallError, ApiError, ConnectionError, IngestError, } from "./errors.js";
|
|
4
|
+
export type { FetchLike, TokenProvider } from "./transport.js";
|
|
5
|
+
export type { ErrorCode, EventCategory, EventInput, FieldError, FieldErrorCode, IngestRowError, IngestSummary, Problem, } from "./types.js";
|
package/dist/index.js
ADDED
package/dist/ingest.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { EventInput, IngestResult, IngestSummary } from "./types.js";
|
|
2
|
+
/** The most events one batch holds. */
|
|
3
|
+
export declare const MAX_BATCH_EVENTS = 500;
|
|
4
|
+
/** The API's body limit for a batch, in bytes, inclusive. */
|
|
5
|
+
export declare const MAX_BODY_BYTES = 5000000;
|
|
6
|
+
/** The largest event that fits in a batch alone. */
|
|
7
|
+
export declare const MAX_EVENT_BYTES: number;
|
|
8
|
+
/** Sends one batch body of `count` events and resolves with the API's outcome for it. */
|
|
9
|
+
export type SendBatch = (body: Uint8Array, count: number, signal: AbortSignal | undefined) => Promise<IngestResult>;
|
|
10
|
+
/**
|
|
11
|
+
* Encodes one event as compact JSON. Every member the object holds is
|
|
12
|
+
* written, in its own order; a member set to `undefined` is omitted, as
|
|
13
|
+
* `JSON.stringify` omits it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function encodeEvent(event: EventInput, index: number): string;
|
|
16
|
+
/**
|
|
17
|
+
* Reads a `2xx` ingest body for a batch of `count` events as an
|
|
18
|
+
* `IngestResult`, or `undefined` when it is not one. A row error whose
|
|
19
|
+
* `index` is not a position in the batch makes the body unreadable, as a
|
|
20
|
+
* body of the wrong shape is.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readIngestResult(value: unknown, count: number): IngestResult | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Sends any number of events in as many batches as the limits require,
|
|
25
|
+
* one after another, and combines their outcomes. Stops at the first batch
|
|
26
|
+
* that fails, throwing an `IngestError`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function ingest(events: readonly EventInput[], send: SendBatch, signal: AbortSignal | undefined): Promise<IngestSummary>;
|
package/dist/ingest.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// The ingest helper: assigning ids, serialising, splitting into batches,
|
|
2
|
+
// sending them one at a time, and combining the outcomes.
|
|
3
|
+
import { isDecimalText } from "./decimal.js";
|
|
4
|
+
import { IngestError } from "./errors.js";
|
|
5
|
+
import { uuidv4 } from "./uuid.js";
|
|
6
|
+
/** The most events one batch holds. */
|
|
7
|
+
export const MAX_BATCH_EVENTS = 500;
|
|
8
|
+
/** The API's body limit for a batch, in bytes, inclusive. */
|
|
9
|
+
export const MAX_BODY_BYTES = 5_000_000;
|
|
10
|
+
/** The bytes of `{"events":[` and `]}` around a batch's events. */
|
|
11
|
+
const ENVELOPE_BYTES = 13;
|
|
12
|
+
/** The largest event that fits in a batch alone. */
|
|
13
|
+
export const MAX_EVENT_BYTES = MAX_BODY_BYTES - ENVELOPE_BYTES;
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
const PREFIX = encoder.encode('{"events":[');
|
|
16
|
+
const SUFFIX = encoder.encode("]}");
|
|
17
|
+
const COMMA = 0x2c;
|
|
18
|
+
// A UTF-16 surrogate without its partner, which UTF-8 cannot encode.
|
|
19
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
20
|
+
function checkText(text, index) {
|
|
21
|
+
if (LONE_SURROGATE.test(text)) {
|
|
22
|
+
throw new TypeError(`allisall: event ${index} holds text that is not valid Unicode (a lone surrogate)`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** `JSON.stringify`, refusing text that is not valid Unicode. */
|
|
26
|
+
function stringify(value, index) {
|
|
27
|
+
const text = JSON.stringify(value, (key, inner) => {
|
|
28
|
+
checkText(key, index);
|
|
29
|
+
if (typeof inner === "string")
|
|
30
|
+
checkText(inner, index);
|
|
31
|
+
return inner;
|
|
32
|
+
});
|
|
33
|
+
return text;
|
|
34
|
+
}
|
|
35
|
+
const NUMERIC_MEMBERS = new Set([
|
|
36
|
+
"quantity",
|
|
37
|
+
"cost",
|
|
38
|
+
"tokens_in",
|
|
39
|
+
"tokens_out",
|
|
40
|
+
]);
|
|
41
|
+
function encodeMember(name, value, index) {
|
|
42
|
+
if (typeof value === "number" && NUMERIC_MEMBERS.has(name)) {
|
|
43
|
+
if (!Number.isFinite(value)) {
|
|
44
|
+
throw new TypeError(`allisall: event ${index} has ${name} ${value}; it must be a finite number`);
|
|
45
|
+
}
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
if (name === "cost" && typeof value === "string") {
|
|
49
|
+
// A Decimal is written unquoted, so its syntax is checked again here:
|
|
50
|
+
// an unbranded string from untyped code must not inject JSON.
|
|
51
|
+
if (!isDecimalText(value)) {
|
|
52
|
+
throw new TypeError(`allisall: event ${index} has cost ${JSON.stringify(value)}, which is not a decimal; build it with decimal()`);
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
if (name === "timestamp" && value instanceof Date) {
|
|
57
|
+
if (Number.isNaN(value.getTime())) {
|
|
58
|
+
throw new TypeError(`allisall: event ${index} has an invalid Date as its timestamp`);
|
|
59
|
+
}
|
|
60
|
+
return JSON.stringify(value.toISOString());
|
|
61
|
+
}
|
|
62
|
+
return stringify(value, index);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Encodes one event as compact JSON. Every member the object holds is
|
|
66
|
+
* written, in its own order; a member set to `undefined` is omitted, as
|
|
67
|
+
* `JSON.stringify` omits it.
|
|
68
|
+
*/
|
|
69
|
+
export function encodeEvent(event, index) {
|
|
70
|
+
const members = [];
|
|
71
|
+
for (const [name, value] of Object.entries(event)) {
|
|
72
|
+
if (value === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
const text = encodeMember(name, value, index);
|
|
75
|
+
if (text === undefined)
|
|
76
|
+
continue;
|
|
77
|
+
members.push(`${stringify(name, index)}:${text}`);
|
|
78
|
+
}
|
|
79
|
+
return `{${members.join(",")}}`;
|
|
80
|
+
}
|
|
81
|
+
function needsId(id) {
|
|
82
|
+
return id === undefined || id === null || id === "";
|
|
83
|
+
}
|
|
84
|
+
function prepare(events) {
|
|
85
|
+
return events.map((original, index) => {
|
|
86
|
+
if (typeof original !== "object" ||
|
|
87
|
+
original === null ||
|
|
88
|
+
Array.isArray(original)) {
|
|
89
|
+
throw new TypeError(`allisall: event ${index} is not an object`);
|
|
90
|
+
}
|
|
91
|
+
const event = { ...original };
|
|
92
|
+
if (needsId(event.event_id))
|
|
93
|
+
event.event_id = uuidv4();
|
|
94
|
+
return { index, event, bytes: encoder.encode(encodeEvent(event, index)) };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function tooLarge(p) {
|
|
98
|
+
return {
|
|
99
|
+
index: p.index,
|
|
100
|
+
code: "too_large",
|
|
101
|
+
pointer: `/events/${p.index}`,
|
|
102
|
+
detail: `The event serialises to ${p.bytes.length} bytes and was not sent. ` +
|
|
103
|
+
`The SDK sends an event of at most ${MAX_EVENT_BYTES} bytes, so that a batch holding it alone ` +
|
|
104
|
+
`stays within the API's body limit of ${MAX_BODY_BYTES} bytes.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Packs events into batches in input order. A batch closes at 500 events,
|
|
109
|
+
* or before an event that would take its body over 5,000,000 bytes. An
|
|
110
|
+
* event too large to send alone joins no batch and becomes a row error.
|
|
111
|
+
*/
|
|
112
|
+
function split(prepared) {
|
|
113
|
+
const batches = [];
|
|
114
|
+
const rejected = [];
|
|
115
|
+
let current;
|
|
116
|
+
for (const p of prepared) {
|
|
117
|
+
const size = p.bytes.length;
|
|
118
|
+
if (size > MAX_EVENT_BYTES) {
|
|
119
|
+
rejected.push(tooLarge(p));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (current !== undefined &&
|
|
123
|
+
(current.events.length >= MAX_BATCH_EVENTS ||
|
|
124
|
+
current.size + 1 + size > MAX_BODY_BYTES)) {
|
|
125
|
+
current = undefined;
|
|
126
|
+
}
|
|
127
|
+
if (current === undefined) {
|
|
128
|
+
current = { events: [p], size: ENVELOPE_BYTES + size };
|
|
129
|
+
batches.push(current);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
current.events.push(p);
|
|
133
|
+
current.size += 1 + size;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { batches, rejected };
|
|
137
|
+
}
|
|
138
|
+
/** Writes a batch as `{"events":[e1,e2,…,en]}`, reusing each event's bytes. */
|
|
139
|
+
function body(batch) {
|
|
140
|
+
const out = new Uint8Array(batch.size);
|
|
141
|
+
out.set(PREFIX, 0);
|
|
142
|
+
let at = PREFIX.length;
|
|
143
|
+
batch.events.forEach((p, i) => {
|
|
144
|
+
if (i > 0)
|
|
145
|
+
out[at++] = COMMA;
|
|
146
|
+
out.set(p.bytes, at);
|
|
147
|
+
at += p.bytes.length;
|
|
148
|
+
});
|
|
149
|
+
out.set(SUFFIX, at);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
const POINTER_ROW = /^\/events\/(\d+)(?=\/|$)/;
|
|
153
|
+
/** Rewrites a batch's row error to the caller's list. */
|
|
154
|
+
function rewrite(error, batch) {
|
|
155
|
+
const toCaller = (i) => batch.events[i]?.index ?? i;
|
|
156
|
+
const pointer = error.pointer.replace(POINTER_ROW, (_, row) => `/events/${toCaller(Number(row))}`);
|
|
157
|
+
return { ...error, index: toCaller(error.index), pointer };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Reads a `2xx` ingest body for a batch of `count` events as an
|
|
161
|
+
* `IngestResult`, or `undefined` when it is not one. A row error whose
|
|
162
|
+
* `index` is not a position in the batch makes the body unreadable, as a
|
|
163
|
+
* body of the wrong shape is.
|
|
164
|
+
*/
|
|
165
|
+
export function readIngestResult(value, count) {
|
|
166
|
+
if (typeof value !== "object" || value === null)
|
|
167
|
+
return undefined;
|
|
168
|
+
const accepted = Reflect.get(value, "accepted");
|
|
169
|
+
const duplicates = Reflect.get(value, "duplicates");
|
|
170
|
+
const rejected = Reflect.get(value, "rejected");
|
|
171
|
+
const errors = Reflect.get(value, "errors");
|
|
172
|
+
const truncated = Reflect.get(value, "errors_truncated");
|
|
173
|
+
if (typeof accepted !== "number" ||
|
|
174
|
+
typeof duplicates !== "number" ||
|
|
175
|
+
typeof rejected !== "number" ||
|
|
176
|
+
typeof truncated !== "boolean" ||
|
|
177
|
+
!Array.isArray(errors)) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const rows = [];
|
|
181
|
+
for (const e of errors) {
|
|
182
|
+
if (typeof e !== "object" || e === null)
|
|
183
|
+
return undefined;
|
|
184
|
+
const index = Reflect.get(e, "index");
|
|
185
|
+
const code = Reflect.get(e, "code");
|
|
186
|
+
const pointer = Reflect.get(e, "pointer");
|
|
187
|
+
const detail = Reflect.get(e, "detail");
|
|
188
|
+
if (typeof index !== "number" ||
|
|
189
|
+
typeof code !== "string" ||
|
|
190
|
+
typeof pointer !== "string" ||
|
|
191
|
+
typeof detail !== "string") {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
if (!Number.isInteger(index) || index < 0 || index >= count) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
rows.push({ index, code, pointer, detail });
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
accepted,
|
|
201
|
+
duplicates,
|
|
202
|
+
rejected,
|
|
203
|
+
errors: rows,
|
|
204
|
+
errors_truncated: truncated,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Sends any number of events in as many batches as the limits require,
|
|
209
|
+
* one after another, and combines their outcomes. Stops at the first batch
|
|
210
|
+
* that fails, throwing an `IngestError`.
|
|
211
|
+
*/
|
|
212
|
+
export async function ingest(events, send, signal) {
|
|
213
|
+
const { batches, rejected } = split(prepare(events));
|
|
214
|
+
const summary = {
|
|
215
|
+
accepted: 0,
|
|
216
|
+
duplicates: 0,
|
|
217
|
+
rejected: rejected.length,
|
|
218
|
+
errors: [...rejected],
|
|
219
|
+
errors_truncated: false,
|
|
220
|
+
batches: 0,
|
|
221
|
+
};
|
|
222
|
+
const finish = () => {
|
|
223
|
+
// Stable, so a row's entries keep the order the API gave them.
|
|
224
|
+
summary.errors.sort((a, b) => a.index - b.index);
|
|
225
|
+
return summary;
|
|
226
|
+
};
|
|
227
|
+
for (const [i, batch] of batches.entries()) {
|
|
228
|
+
let result;
|
|
229
|
+
try {
|
|
230
|
+
result = await send(body(batch), batch.events.length, signal);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
// Every failure of a batch is wrapped, the token provider's included,
|
|
234
|
+
// so the caller keeps the summary and the generated ids in remaining.
|
|
235
|
+
const remaining = batches.slice(i).flatMap((b) => b.events);
|
|
236
|
+
throw new IngestError(err, finish(), remaining.map((p) => p.event), remaining[0]?.index ?? 0);
|
|
237
|
+
}
|
|
238
|
+
summary.accepted += result.accepted;
|
|
239
|
+
summary.duplicates += result.duplicates;
|
|
240
|
+
summary.rejected += result.rejected;
|
|
241
|
+
summary.errors_truncated ||= result.errors_truncated;
|
|
242
|
+
for (const error of result.errors)
|
|
243
|
+
summary.errors.push(rewrite(error, batch));
|
|
244
|
+
summary.batches++;
|
|
245
|
+
}
|
|
246
|
+
return finish();
|
|
247
|
+
}
|
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The longest `Retry-After` the SDK honours. A longer one ends the call. */
|
|
2
|
+
export declare const MAX_RETRY_AFTER_MS = 60000;
|
|
3
|
+
/** The statuses worth repeating: a proxy's `408`, `429`, `500`, and a gateway's `502`, `503`, `504`. */
|
|
4
|
+
export declare function isRetryableStatus(status: number): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* The wait before retry `n` (1 for the first retry) when the response
|
|
7
|
+
* carried no usable `Retry-After`: `min(0.5 s × 2^(n−1), 8 s)`, multiplied
|
|
8
|
+
* by a random factor between 0.75 and 1.
|
|
9
|
+
*/
|
|
10
|
+
export declare function backoffMs(retry: number): number;
|
|
11
|
+
/**
|
|
12
|
+
* Parses a `Retry-After` value, delay-seconds or an HTTP-date (RFC 9110
|
|
13
|
+
* §10.2.3), into milliseconds from `now`. Returns `undefined` for a value
|
|
14
|
+
* that does not parse or names a time in the past.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseRetryAfter(value: string | null, now: number): number | undefined;
|
|
17
|
+
/** Waits `ms` milliseconds, or rejects with the signal's reason when it aborts first. */
|
|
18
|
+
export declare function sleep(ms: number, signal: AbortSignal | undefined): Promise<void>;
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The retry decision and the wait between attempts.
|
|
2
|
+
/** The longest `Retry-After` the SDK honours. A longer one ends the call. */
|
|
3
|
+
export const MAX_RETRY_AFTER_MS = 60_000;
|
|
4
|
+
const INITIAL_BACKOFF_MS = 500;
|
|
5
|
+
const MAX_BACKOFF_MS = 8_000;
|
|
6
|
+
/** The statuses worth repeating: a proxy's `408`, `429`, `500`, and a gateway's `502`, `503`, `504`. */
|
|
7
|
+
export function isRetryableStatus(status) {
|
|
8
|
+
return (status === 408 ||
|
|
9
|
+
status === 429 ||
|
|
10
|
+
status === 500 ||
|
|
11
|
+
status === 502 ||
|
|
12
|
+
status === 503 ||
|
|
13
|
+
status === 504);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The wait before retry `n` (1 for the first retry) when the response
|
|
17
|
+
* carried no usable `Retry-After`: `min(0.5 s × 2^(n−1), 8 s)`, multiplied
|
|
18
|
+
* by a random factor between 0.75 and 1.
|
|
19
|
+
*/
|
|
20
|
+
export function backoffMs(retry) {
|
|
21
|
+
const base = Math.min(INITIAL_BACKOFF_MS * 2 ** (retry - 1), MAX_BACKOFF_MS);
|
|
22
|
+
return base * (1 - Math.random() * 0.25);
|
|
23
|
+
}
|
|
24
|
+
const MONTHS = [
|
|
25
|
+
"Jan",
|
|
26
|
+
"Feb",
|
|
27
|
+
"Mar",
|
|
28
|
+
"Apr",
|
|
29
|
+
"May",
|
|
30
|
+
"Jun",
|
|
31
|
+
"Jul",
|
|
32
|
+
"Aug",
|
|
33
|
+
"Sep",
|
|
34
|
+
"Oct",
|
|
35
|
+
"Nov",
|
|
36
|
+
"Dec",
|
|
37
|
+
];
|
|
38
|
+
// RFC 9110 §5.6.7: IMF-fixdate, and the two obsolete forms a recipient must accept.
|
|
39
|
+
const IMF_FIXDATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) ([A-Z][a-z]{2}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
|
40
|
+
const RFC850_DATE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-([A-Z][a-z]{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
|
41
|
+
const ASCTIME_DATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) ([A-Z][a-z]{2}) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
|
|
42
|
+
function utc(year, month, day, hour, minute, second) {
|
|
43
|
+
const m = MONTHS.indexOf(month);
|
|
44
|
+
if (m < 0)
|
|
45
|
+
return undefined;
|
|
46
|
+
const ms = Date.UTC(year, m, Number(day.trim()), Number(hour), Number(minute), Number(second));
|
|
47
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
48
|
+
}
|
|
49
|
+
function parseHttpDate(value, now) {
|
|
50
|
+
let match = IMF_FIXDATE.exec(value);
|
|
51
|
+
if (match !== null) {
|
|
52
|
+
const [, day = "", month = "", year = "", h = "", m = "", s = ""] = match;
|
|
53
|
+
return utc(Number(year), month, day, h, m, s);
|
|
54
|
+
}
|
|
55
|
+
match = RFC850_DATE.exec(value);
|
|
56
|
+
if (match !== null) {
|
|
57
|
+
const [, day = "", month = "", yy = "", h = "", m = "", s = ""] = match;
|
|
58
|
+
// RFC 9110 §5.6.7: a two-digit year more than 50 years in the future is in the past century.
|
|
59
|
+
const thisYear = new Date(now).getUTCFullYear();
|
|
60
|
+
let year = 2000 + Number(yy);
|
|
61
|
+
if (year > thisYear + 50)
|
|
62
|
+
year -= 100;
|
|
63
|
+
return utc(year, month, day, h, m, s);
|
|
64
|
+
}
|
|
65
|
+
match = ASCTIME_DATE.exec(value);
|
|
66
|
+
if (match !== null) {
|
|
67
|
+
const [, month = "", day = "", h = "", m = "", s = "", year = ""] = match;
|
|
68
|
+
return utc(Number(year), month, day, h, m, s);
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Parses a `Retry-After` value, delay-seconds or an HTTP-date (RFC 9110
|
|
74
|
+
* §10.2.3), into milliseconds from `now`. Returns `undefined` for a value
|
|
75
|
+
* that does not parse or names a time in the past.
|
|
76
|
+
*/
|
|
77
|
+
export function parseRetryAfter(value, now) {
|
|
78
|
+
if (value === null)
|
|
79
|
+
return undefined;
|
|
80
|
+
const text = value.trim();
|
|
81
|
+
if (/^\d+$/.test(text))
|
|
82
|
+
return Number(text) * 1000;
|
|
83
|
+
const at = parseHttpDate(text, now);
|
|
84
|
+
if (at === undefined || at < now)
|
|
85
|
+
return undefined;
|
|
86
|
+
return at - now;
|
|
87
|
+
}
|
|
88
|
+
/** Waits `ms` milliseconds, or rejects with the signal's reason when it aborts first. */
|
|
89
|
+
export function sleep(ms, signal) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
if (signal?.aborted) {
|
|
92
|
+
reject(signal.reason);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const onAbort = () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
reject(signal?.reason);
|
|
98
|
+
};
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
signal?.removeEventListener("abort", onAbort);
|
|
101
|
+
resolve();
|
|
102
|
+
}, ms);
|
|
103
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports whether the SDK runs in a browser: a window, where `document`
|
|
3
|
+
* is defined, or a Web Worker, where `globalThis` is an instance of
|
|
4
|
+
* `WorkerGlobalScope`. Node, Deno, Bun and edge runtimes define neither.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isBrowser(): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Reads an environment variable from `globalThis.process.env`, where it
|
|
9
|
+
* exists. Returns `undefined` for a variable that is unset or empty.
|
|
10
|
+
*/
|
|
11
|
+
export declare function readEnv(name: string): string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* The `User-Agent` to send: `allisall-ts/<version> node/<node version>`.
|
|
14
|
+
* A server runtime that reports no Node version, such as an edge runtime,
|
|
15
|
+
* sends `allisall-ts/<version>` alone. A browser sends none.
|
|
16
|
+
*/
|
|
17
|
+
export declare function userAgent(): string | undefined;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// What the SDK learns about the runtime it runs in: whether it is a
|
|
2
|
+
// browser, the environment, and the User-Agent. Globals are read through
|
|
3
|
+
// `globalThis` by name, so a runtime without them, such as a browser
|
|
4
|
+
// without `process`, is never touched.
|
|
5
|
+
import { VERSION } from "./version.js";
|
|
6
|
+
function globalValue(name) {
|
|
7
|
+
const value = Reflect.get(globalThis, name);
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function isObject(value) {
|
|
11
|
+
return typeof value === "object" && value !== null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Reports whether the SDK runs in a browser: a window, where `document`
|
|
15
|
+
* is defined, or a Web Worker, where `globalThis` is an instance of
|
|
16
|
+
* `WorkerGlobalScope`. Node, Deno, Bun and edge runtimes define neither.
|
|
17
|
+
*/
|
|
18
|
+
export function isBrowser() {
|
|
19
|
+
if (globalValue("document") !== undefined)
|
|
20
|
+
return true;
|
|
21
|
+
const scope = globalValue("WorkerGlobalScope");
|
|
22
|
+
return typeof scope === "function" && globalThis instanceof scope;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Reads an environment variable from `globalThis.process.env`, where it
|
|
26
|
+
* exists. Returns `undefined` for a variable that is unset or empty.
|
|
27
|
+
*/
|
|
28
|
+
export function readEnv(name) {
|
|
29
|
+
const process = globalValue("process");
|
|
30
|
+
if (!isObject(process))
|
|
31
|
+
return undefined;
|
|
32
|
+
const env = Reflect.get(process, "env");
|
|
33
|
+
if (!isObject(env))
|
|
34
|
+
return undefined;
|
|
35
|
+
const value = Reflect.get(env, name);
|
|
36
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
37
|
+
}
|
|
38
|
+
function nodeVersion() {
|
|
39
|
+
const process = globalValue("process");
|
|
40
|
+
if (!isObject(process))
|
|
41
|
+
return undefined;
|
|
42
|
+
const versions = Reflect.get(process, "versions");
|
|
43
|
+
if (!isObject(versions))
|
|
44
|
+
return undefined;
|
|
45
|
+
const node = Reflect.get(versions, "node");
|
|
46
|
+
return typeof node === "string" && node !== "" ? node : undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The `User-Agent` to send: `allisall-ts/<version> node/<node version>`.
|
|
50
|
+
* A server runtime that reports no Node version, such as an edge runtime,
|
|
51
|
+
* sends `allisall-ts/<version>` alone. A browser sends none.
|
|
52
|
+
*/
|
|
53
|
+
export function userAgent() {
|
|
54
|
+
if (isBrowser())
|
|
55
|
+
return undefined;
|
|
56
|
+
const node = nodeVersion();
|
|
57
|
+
return node === undefined
|
|
58
|
+
? `allisall-ts/${VERSION}`
|
|
59
|
+
: `allisall-ts/${VERSION} node/${node}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Problem } from "./types.js";
|
|
2
|
+
/** A `fetch`-compatible function. */
|
|
3
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
4
|
+
/** A callback returning the bearer token to send, called before every attempt. */
|
|
5
|
+
export type TokenProvider = () => string | Promise<string>;
|
|
6
|
+
/** Where the bearer token comes from. */
|
|
7
|
+
export type Credential = {
|
|
8
|
+
readonly kind: "apiKey";
|
|
9
|
+
readonly apiKey: string;
|
|
10
|
+
} | {
|
|
11
|
+
readonly kind: "provider";
|
|
12
|
+
readonly provider: TokenProvider;
|
|
13
|
+
};
|
|
14
|
+
/** Everything an attempt needs, fixed when the client is constructed. */
|
|
15
|
+
export interface TransportConfig {
|
|
16
|
+
readonly baseURL: URL;
|
|
17
|
+
readonly credential: Credential;
|
|
18
|
+
readonly timeout: number;
|
|
19
|
+
readonly maxRetries: number;
|
|
20
|
+
readonly fetch: FetchLike | undefined;
|
|
21
|
+
/** Whether a token that is an API key is refused: a browser without the opt-in. */
|
|
22
|
+
readonly refuseApiKeys: boolean;
|
|
23
|
+
/** The `User-Agent` to send; `undefined` in a browser. */
|
|
24
|
+
readonly userAgent: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
/** The message of the error refusing an API key in a browser. */
|
|
27
|
+
export declare const BROWSER_KEY_MESSAGE: string;
|
|
28
|
+
/** Reports whether a bearer token is an API key, which starts `aia_`. */
|
|
29
|
+
export declare function isApiKey(token: string): boolean;
|
|
30
|
+
/** Joins the base URL and an operation's path with exactly one `/`. */
|
|
31
|
+
export declare function joinURL(base: URL, path: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Reports whether every character of a credential, already trimmed, is
|
|
34
|
+
* printable ASCII (U+0021 to U+007E), as a bearer credential is. Anything
|
|
35
|
+
* else, a control character, an inner space or a non-ASCII character,
|
|
36
|
+
* cannot travel in the header. The check never quotes the credential, so
|
|
37
|
+
* no error built from it can leak the secret.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isPrintableCredential(credential: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Reads an `application/problem+json` body as a problem document. Returns
|
|
42
|
+
* `undefined` for any other body, or one that is not a JSON object. A
|
|
43
|
+
* member of the wrong type is left out on its own, as is a malformed
|
|
44
|
+
* entry of `errors`; the rest of the problem is kept. Members the SDK does
|
|
45
|
+
* not know are ignored.
|
|
46
|
+
*/
|
|
47
|
+
export declare function readProblem(contentType: string | null, text: string): Problem | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Posts a JSON body, retrying a failure worth repeating. The body is the
|
|
50
|
+
* same bytes on every attempt. Each attempt reads a fresh token and sends
|
|
51
|
+
* a fresh `X-Request-Id`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function post<T>(config: TransportConfig, path: string, body: Uint8Array, read: (body: unknown) => T | undefined, signal: AbortSignal | undefined): Promise<T>;
|