@ohm_studio/sdk-core 0.7.0 → 0.9.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/dist/client.d.ts +72 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +369 -50
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +33 -27
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +19 -1
- package/dist/errors.js.map +1 -1
- package/dist/types.d.ts +138 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -4,6 +4,52 @@ import { mockResponseFor } from "./mock";
|
|
|
4
4
|
const DEFAULT_BASE_URL = "https://api.ohm.doctor";
|
|
5
5
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
6
6
|
const DEFAULT_MAX_RETRIES = 2;
|
|
7
|
+
// 500 MB hard cap before we even attempt to upload. At 16 kHz mono WAV
|
|
8
|
+
// that's ~2 hours of audio — far longer than any realistic clinical
|
|
9
|
+
// recording. The server can chunk above 1 hour, but anything past 2 hr
|
|
10
|
+
// is almost certainly a mis-attached file (a hospital lecture, a video
|
|
11
|
+
// dump). Fail early with a clear message rather than letting the
|
|
12
|
+
// customer wait for a slow multi-GB upload to error out mid-transit.
|
|
13
|
+
const MAX_AUDIO_BYTES = 500 * 1024 * 1024;
|
|
14
|
+
/**
|
|
15
|
+
* Best-effort `User-Agent` for Node. Browsers + RN reject custom UA
|
|
16
|
+
* via fetch (forbidden header), so we only set it on Node-flavoured
|
|
17
|
+
* runtimes. Empty string disables the header entirely.
|
|
18
|
+
*/
|
|
19
|
+
function buildUserAgent(sdkVersion) {
|
|
20
|
+
// @ts-ignore — runtime probe
|
|
21
|
+
const proc = typeof process !== "undefined" ? process : undefined;
|
|
22
|
+
if (proc?.versions?.node && !proc.versions.bun) {
|
|
23
|
+
return `ohm-sdk/${sdkVersion} (node/${proc.versions.node}; ${proc.platform} ${proc.arch})`;
|
|
24
|
+
}
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
/** Generate a UUID v4. Uses native crypto.randomUUID() when available. */
|
|
28
|
+
function uuidv4() {
|
|
29
|
+
// Node 16+, modern browsers, RN 0.71+ all ship crypto.randomUUID.
|
|
30
|
+
const g = globalThis;
|
|
31
|
+
if (g.crypto?.randomUUID)
|
|
32
|
+
return g.crypto.randomUUID();
|
|
33
|
+
// Fallback for very old hosts — Math.random is acceptable for an
|
|
34
|
+
// idempotency key (server only uses it as a dedupe token, not a secret).
|
|
35
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
36
|
+
const r = (Math.random() * 16) | 0;
|
|
37
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
38
|
+
return v.toString(16);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Safe-fire a hook; swallow exceptions so user bugs don't break requests. */
|
|
42
|
+
function safeHook(fn, arg) {
|
|
43
|
+
if (!fn)
|
|
44
|
+
return;
|
|
45
|
+
try {
|
|
46
|
+
fn(arg);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
// eslint-disable-next-line no-console
|
|
50
|
+
console.warn("[ohm-sdk] hook threw — swallowing:", err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
7
53
|
/**
|
|
8
54
|
* Platform-agnostic core client. Subclasses (sdk-js, sdk-react-native)
|
|
9
55
|
* supply the platform-specific multipart/audio adapter via the `attachAudio`
|
|
@@ -14,11 +60,19 @@ export class OHMCoreClient {
|
|
|
14
60
|
apiKey;
|
|
15
61
|
jwt;
|
|
16
62
|
timeoutMs;
|
|
63
|
+
totalTimeoutMs;
|
|
17
64
|
maxRetries;
|
|
18
65
|
fetchImpl;
|
|
19
66
|
onUsage;
|
|
67
|
+
hooks;
|
|
68
|
+
disableAutoIdempotency;
|
|
69
|
+
userAgent;
|
|
20
70
|
_mock;
|
|
21
71
|
_mockResponses;
|
|
72
|
+
/** Cached options for `withOverrides`. */
|
|
73
|
+
_opts;
|
|
74
|
+
/** SDK version stamped on `X-OHM-Client` + `User-Agent`. */
|
|
75
|
+
static SDK_VERSION = "0.8.0";
|
|
22
76
|
constructor(init) {
|
|
23
77
|
// Accept either a bare `ohms_live_…` string (`new OHM("…")`) or the
|
|
24
78
|
// full options object. Most customers want the one-liner.
|
|
@@ -30,16 +84,71 @@ export class OHMCoreClient {
|
|
|
30
84
|
message: "OHM client requires an apiKey. Pass it as `new OHM('ohms_live_…')` or `new OHM({ apiKey: '…' })`.",
|
|
31
85
|
});
|
|
32
86
|
}
|
|
87
|
+
this._opts = opts;
|
|
33
88
|
this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
34
89
|
this.apiKey = opts.apiKey;
|
|
35
90
|
this.jwt = opts.jwt;
|
|
36
91
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
92
|
+
this.totalTimeoutMs = opts.totalTimeoutMs;
|
|
37
93
|
this.maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
38
94
|
this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
39
95
|
this.onUsage = opts.onUsage;
|
|
96
|
+
this.hooks = opts.hooks;
|
|
97
|
+
this.disableAutoIdempotency = !!opts.disableAutoIdempotency;
|
|
98
|
+
this.userAgent = buildUserAgent(OHMCoreClient.SDK_VERSION);
|
|
40
99
|
this._mock = !!opts.mock;
|
|
41
100
|
this._mockResponses = opts.mockResponses;
|
|
42
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Returns the SDK version string. Useful when forwarding the SDK
|
|
104
|
+
* version to your own telemetry pipeline.
|
|
105
|
+
*/
|
|
106
|
+
static getVersion() {
|
|
107
|
+
return OHMCoreClient.SDK_VERSION;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Returns a new client with overridden options for one call. The
|
|
111
|
+
* underlying auth + base URL are inherited; you typically only
|
|
112
|
+
* override `timeoutMs` / `maxRetries` / `totalTimeoutMs` for a
|
|
113
|
+
* single known-slow call.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* const slow = ohm.withOverrides({ timeoutMs: 5 * 60_000 });
|
|
117
|
+
* await slow.audio.extract({ apiSlug, file: bigAudio });
|
|
118
|
+
*/
|
|
119
|
+
withOverrides(overrides) {
|
|
120
|
+
// Build a new instance of the same subclass with merged options.
|
|
121
|
+
// Cast through `unknown` keeps the static type but uses the
|
|
122
|
+
// runtime constructor (sdk-js / sdk-rn / etc).
|
|
123
|
+
const Ctor = this.constructor;
|
|
124
|
+
return new Ctor({ ...this._opts, ...overrides });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Establish a TCP/TLS connection to the API ahead of the first real
|
|
128
|
+
* call. Drops cold-start latency from ~500 ms to ~150 ms on real-world
|
|
129
|
+
* mobile networks. Safe to call multiple times; no-op in mock mode.
|
|
130
|
+
*
|
|
131
|
+
* const ohm = new OHM({ apiKey });
|
|
132
|
+
* void ohm.warmUp(); // fire-and-forget at app boot
|
|
133
|
+
* // ...
|
|
134
|
+
* await ohm.extract({ ... }); // already-warm connection
|
|
135
|
+
*/
|
|
136
|
+
async warmUp() {
|
|
137
|
+
if (this._mock)
|
|
138
|
+
return;
|
|
139
|
+
try {
|
|
140
|
+
await this.fetchImpl(`${this.baseUrl}/api/health`, {
|
|
141
|
+
method: "GET",
|
|
142
|
+
// Short, non-retried, abort-able. We don't care about the
|
|
143
|
+
// response body — only that the TLS handshake completes.
|
|
144
|
+
signal: AbortSignal.timeout(5_000),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Warm-up is best-effort. If the network is offline the real
|
|
149
|
+
// call will surface its own OHMNetworkError.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
43
152
|
/**
|
|
44
153
|
* Audio surface — speech-to-text and audio-to-structured-JSON.
|
|
45
154
|
* Subclasses fill in the platform-specific multipart adapter (browser
|
|
@@ -69,6 +178,7 @@ export class OHMCoreClient {
|
|
|
69
178
|
if (this._mock) {
|
|
70
179
|
return Promise.resolve(mockResponseFor.transcribe(this._mockResponses));
|
|
71
180
|
}
|
|
181
|
+
this.assertFileSize(input.file);
|
|
72
182
|
return this.runMultipart({
|
|
73
183
|
path: "/api/studio/v1/audio/transcribe",
|
|
74
184
|
file: input.file,
|
|
@@ -104,6 +214,7 @@ export class OHMCoreClient {
|
|
|
104
214
|
if (this._mock) {
|
|
105
215
|
return Promise.resolve(mockResponseFor.audioExtract(input.apiSlug, this._mockResponses));
|
|
106
216
|
}
|
|
217
|
+
this.assertFileSize(input.file);
|
|
107
218
|
return this.runMultipart({
|
|
108
219
|
path: `/api/studio/v1/audio/extract/${encodeURIComponent(input.apiSlug)}`,
|
|
109
220
|
file: input.file,
|
|
@@ -125,7 +236,7 @@ export class OHMCoreClient {
|
|
|
125
236
|
* step finishes. Backend uses Server-Sent Events.
|
|
126
237
|
*
|
|
127
238
|
* @example
|
|
128
|
-
* const stream = ohm.audio.
|
|
239
|
+
* const stream = ohm.audio.extractStream({ apiSlug, file });
|
|
129
240
|
* for await (const chunk of stream) {
|
|
130
241
|
* if (chunk.type === "transcript") setT(chunk.transcript);
|
|
131
242
|
* if (chunk.type === "data") setData(chunk.data);
|
|
@@ -136,6 +247,7 @@ export class OHMCoreClient {
|
|
|
136
247
|
if (this._mock) {
|
|
137
248
|
return mockResponseFor.audioExtractStream(input.apiSlug, this._mockResponses);
|
|
138
249
|
}
|
|
250
|
+
this.assertFileSize(input.file);
|
|
139
251
|
return this.runMultipartStream({
|
|
140
252
|
path: `/api/studio/v1/audio/extract/${encodeURIComponent(input.apiSlug)}/stream`,
|
|
141
253
|
file: input.file,
|
|
@@ -186,6 +298,7 @@ export class OHMCoreClient {
|
|
|
186
298
|
createdAt: new Date().toISOString(),
|
|
187
299
|
});
|
|
188
300
|
}
|
|
301
|
+
this.assertFileSize(input.file);
|
|
189
302
|
const path = input.apiSlug
|
|
190
303
|
? `/api/studio/v1/audio/extract/${encodeURIComponent(input.apiSlug)}/jobs`
|
|
191
304
|
: `/api/studio/v1/audio/transcribe/jobs`;
|
|
@@ -270,9 +383,14 @@ export class OHMCoreClient {
|
|
|
270
383
|
* CANCELLED — caller checks status). Never returns mid-state.
|
|
271
384
|
*/
|
|
272
385
|
poll: async (jobId, options = {}) => {
|
|
273
|
-
const
|
|
386
|
+
const initialIntervalMs = options.intervalMs ?? 2000;
|
|
387
|
+
const maxIntervalMs = options.maxIntervalMs ?? 30_000;
|
|
274
388
|
const maxWaitMs = options.maxWaitMs ?? 15 * 60_000;
|
|
275
389
|
const start = Date.now();
|
|
390
|
+
// Exponential backoff capped at maxIntervalMs — protects the
|
|
391
|
+
// worker from a chatty client when a job stays PROCESSING for
|
|
392
|
+
// 10+ minutes. Grows 1.5× per poll: 2 → 3 → 4.5 → ... → 30.
|
|
393
|
+
let interval = initialIntervalMs;
|
|
276
394
|
// eslint-disable-next-line no-constant-condition
|
|
277
395
|
while (true) {
|
|
278
396
|
if (options.signal?.aborted)
|
|
@@ -292,7 +410,8 @@ export class OHMCoreClient {
|
|
|
292
410
|
status: 0,
|
|
293
411
|
});
|
|
294
412
|
}
|
|
295
|
-
await new Promise((r) => setTimeout(r,
|
|
413
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
414
|
+
interval = Math.min(maxIntervalMs, Math.round(interval * 1.5));
|
|
296
415
|
}
|
|
297
416
|
},
|
|
298
417
|
},
|
|
@@ -353,6 +472,67 @@ export class OHMCoreClient {
|
|
|
353
472
|
idempotencyKey: input.idempotencyKey,
|
|
354
473
|
});
|
|
355
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Bulk-extract a batch of text inputs concurrently. Partial failures
|
|
477
|
+
* do NOT fail the batch — each input gets a discriminated-union
|
|
478
|
+
* result (`{ ok: true, data }` or `{ ok: false, error, input }`).
|
|
479
|
+
*
|
|
480
|
+
* Use when replaying historical transcripts, batch-tagging lab
|
|
481
|
+
* reports, or anywhere "10 000 of these need to extract this week".
|
|
482
|
+
*
|
|
483
|
+
* Default concurrency is 4 — enough to amortise network round-trips
|
|
484
|
+
* without blowing the per-key rate limit. Pass a higher cap when you
|
|
485
|
+
* know your key's quota is generous.
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* const results = await ohm.extractBulk(transcripts.map(t => ({
|
|
489
|
+
* apiSlug: "opd-clinic",
|
|
490
|
+
* text: t,
|
|
491
|
+
* })), {
|
|
492
|
+
* concurrency: 8,
|
|
493
|
+
* onProgress: (done, total) => console.log(`${done}/${total}`),
|
|
494
|
+
* });
|
|
495
|
+
* const errored = results.filter(r => !r.ok);
|
|
496
|
+
*/
|
|
497
|
+
async extractBulk(inputs, options = {}) {
|
|
498
|
+
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
499
|
+
const results = new Array(inputs.length);
|
|
500
|
+
let cursor = 0;
|
|
501
|
+
let done = 0;
|
|
502
|
+
const total = inputs.length;
|
|
503
|
+
const signal = options.signal;
|
|
504
|
+
const worker = async () => {
|
|
505
|
+
while (cursor < total) {
|
|
506
|
+
if (signal?.aborted) {
|
|
507
|
+
throw new OHMAbortError();
|
|
508
|
+
}
|
|
509
|
+
const idx = cursor++;
|
|
510
|
+
const input = inputs[idx];
|
|
511
|
+
try {
|
|
512
|
+
const data = await this.extract({ ...input, signal });
|
|
513
|
+
results[idx] = { ok: true, data };
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
results[idx] = {
|
|
517
|
+
ok: false,
|
|
518
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
519
|
+
input,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
done++;
|
|
524
|
+
try {
|
|
525
|
+
options.onProgress?.(done, total);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
/* ignore */
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, total) }, () => worker()));
|
|
534
|
+
return results;
|
|
535
|
+
}
|
|
356
536
|
/**
|
|
357
537
|
* One-line convenience: pass a transcript, get back the data field.
|
|
358
538
|
* Equivalent to `(await ohm.extract({ apiSlug, text })).data` — the most
|
|
@@ -519,7 +699,31 @@ export class OHMCoreClient {
|
|
|
519
699
|
return this.requestRaw(method, path, init, options);
|
|
520
700
|
}
|
|
521
701
|
/**
|
|
522
|
-
*
|
|
702
|
+
* Pre-upload size guard. Throws OHMValidationError BEFORE the
|
|
703
|
+
* multipart request fires so the customer doesn't burn upload time
|
|
704
|
+
* on a multi-GB mis-attached file. Best-effort — file shape varies
|
|
705
|
+
* across web (Blob/File), node (Buffer/Stream), and React-Native; we
|
|
706
|
+
* read whatever size hint is reachable and skip the check when none
|
|
707
|
+
* is. Server-side will reject oversize uploads regardless.
|
|
708
|
+
*/
|
|
709
|
+
assertFileSize(file) {
|
|
710
|
+
const size = typeof file?.size === "number"
|
|
711
|
+
? file.size
|
|
712
|
+
: typeof file?.length === "number"
|
|
713
|
+
? file.length
|
|
714
|
+
: typeof file?.byteLength === "number"
|
|
715
|
+
? file.byteLength
|
|
716
|
+
: undefined;
|
|
717
|
+
if (typeof size === "number" && size > MAX_AUDIO_BYTES) {
|
|
718
|
+
throw new OHMValidationError({
|
|
719
|
+
status: 0,
|
|
720
|
+
message: `Audio file too large (${(size / 1024 / 1024).toFixed(1)} MB). Maximum is ${MAX_AUDIO_BYTES / 1024 / 1024} MB.`,
|
|
721
|
+
fields: ["file"],
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Default SSE-based streaming for audio.extractStream. Subclasses
|
|
523
727
|
* override `runMultipart` to construct the FormData body for their
|
|
524
728
|
* platform; this method reuses that body and parses an SSE event stream
|
|
525
729
|
* off the response.
|
|
@@ -537,7 +741,7 @@ export class OHMCoreClient {
|
|
|
537
741
|
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
538
742
|
else if (this.jwt)
|
|
539
743
|
headers.Authorization = `Bearer ${this.jwt}`;
|
|
540
|
-
headers["X-OHM-Client"] =
|
|
744
|
+
headers["X-OHM-Client"] = `@ohm_studio/sdk-core@${OHMCoreClient.SDK_VERSION}`;
|
|
541
745
|
if (opts.idempotencyKey) {
|
|
542
746
|
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
543
747
|
}
|
|
@@ -614,6 +818,11 @@ export class OHMCoreClient {
|
|
|
614
818
|
});
|
|
615
819
|
}
|
|
616
820
|
async requestRaw(method, path, init, options) {
|
|
821
|
+
// ── Resolve per-call overrides (or fall back to client defaults).
|
|
822
|
+
const perAttemptTimeout = options?.timeoutMs ?? this.timeoutMs;
|
|
823
|
+
const maxRetries = options?.maxRetries ?? this.maxRetries;
|
|
824
|
+
const totalDeadline = options?.totalTimeoutMs ?? this.totalTimeoutMs;
|
|
825
|
+
const deadlineAt = totalDeadline != null ? Date.now() + totalDeadline : undefined;
|
|
617
826
|
const url = `${this.baseUrl}${path}`;
|
|
618
827
|
const headers = new Headers(init.headers || {});
|
|
619
828
|
if (this.apiKey)
|
|
@@ -621,13 +830,45 @@ export class OHMCoreClient {
|
|
|
621
830
|
else if (this.jwt)
|
|
622
831
|
headers.set("Authorization", `Bearer ${this.jwt}`);
|
|
623
832
|
if (!headers.has("X-OHM-Client")) {
|
|
624
|
-
headers.set("X-OHM-Client",
|
|
833
|
+
headers.set("X-OHM-Client", `@ohm_studio/sdk-core@${OHMCoreClient.SDK_VERSION}`);
|
|
834
|
+
}
|
|
835
|
+
// User-Agent — Node only. Browsers + RN reject custom UA.
|
|
836
|
+
if (this.userAgent && !headers.has("User-Agent")) {
|
|
837
|
+
try {
|
|
838
|
+
headers.set("User-Agent", this.userAgent);
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
// Some hosts forbid setting User-Agent — swallow.
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// ── Idempotency-Key: caller-supplied wins. Otherwise auto-generate
|
|
845
|
+
// for unsafe methods (POST/PATCH/PUT/DELETE) unless explicitly
|
|
846
|
+
// disabled. `null` from the caller is an explicit opt-out.
|
|
847
|
+
const isUnsafe = method === "POST" ||
|
|
848
|
+
method === "PATCH" ||
|
|
849
|
+
method === "PUT" ||
|
|
850
|
+
method === "DELETE";
|
|
851
|
+
let idempotencyKey;
|
|
852
|
+
if (options?.idempotencyKey === null) {
|
|
853
|
+
// explicit opt-out — leave header off
|
|
854
|
+
}
|
|
855
|
+
else if (typeof options?.idempotencyKey === "string") {
|
|
856
|
+
idempotencyKey = options.idempotencyKey;
|
|
625
857
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if (options?.idempotencyKey) {
|
|
629
|
-
headers.set("Idempotency-Key", options.idempotencyKey);
|
|
858
|
+
else if (isUnsafe && !this.disableAutoIdempotency) {
|
|
859
|
+
idempotencyKey = uuidv4();
|
|
630
860
|
}
|
|
861
|
+
if (idempotencyKey) {
|
|
862
|
+
headers.set("Idempotency-Key", idempotencyKey);
|
|
863
|
+
}
|
|
864
|
+
// ── Body inspection for keepalive eligibility. Browser fetch
|
|
865
|
+
// enforces a 64 KB cap on keepalive bodies; we play it safe at
|
|
866
|
+
// 60 KB and skip multipart bodies entirely.
|
|
867
|
+
const bodyAsString = typeof init.body === "string" ? init.body : "";
|
|
868
|
+
const keepaliveEligible = isUnsafe &&
|
|
869
|
+
bodyAsString.length > 0 &&
|
|
870
|
+
bodyAsString.length < 60_000 &&
|
|
871
|
+
headers.get("content-type")?.includes("application/json") === true;
|
|
631
872
|
// Caller-supplied signal short-circuits before any work is started —
|
|
632
873
|
// matches DOM fetch() semantics and avoids burning a retry on an
|
|
633
874
|
// already-cancelled request.
|
|
@@ -635,25 +876,45 @@ export class OHMCoreClient {
|
|
|
635
876
|
throw new OHMAbortError();
|
|
636
877
|
}
|
|
637
878
|
let lastError;
|
|
638
|
-
for (let attempt = 0; attempt <=
|
|
879
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
880
|
+
// ── Deadline check before each attempt.
|
|
881
|
+
if (deadlineAt != null && Date.now() >= deadlineAt) {
|
|
882
|
+
throw new OHMTimeoutError({
|
|
883
|
+
message: `Total request deadline (${totalDeadline}ms) exceeded after ${attempt} attempt(s)`,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
// ── Per-attempt timeout: never exceed the remaining deadline.
|
|
887
|
+
const remaining = deadlineAt != null ? Math.max(50, deadlineAt - Date.now()) : Infinity;
|
|
888
|
+
const attemptTimeout = Math.min(perAttemptTimeout, remaining);
|
|
639
889
|
const ac = new AbortController();
|
|
640
|
-
const t = setTimeout(() => ac.abort(),
|
|
641
|
-
// Bridge the caller's signal into our internal AbortController so
|
|
642
|
-
// either source (timeout, user cancel) trips the same fetch abort.
|
|
890
|
+
const t = setTimeout(() => ac.abort(), attemptTimeout);
|
|
643
891
|
let onCallerAbort;
|
|
644
892
|
if (options?.signal) {
|
|
645
893
|
onCallerAbort = () => ac.abort();
|
|
646
894
|
options.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
647
895
|
}
|
|
896
|
+
safeHook(this.hooks?.onRequest, {
|
|
897
|
+
method,
|
|
898
|
+
url,
|
|
899
|
+
attempt,
|
|
900
|
+
idempotencyKey,
|
|
901
|
+
});
|
|
648
902
|
const tStart = Date.now();
|
|
649
903
|
try {
|
|
650
|
-
const
|
|
904
|
+
const fetchInit = {
|
|
651
905
|
...init,
|
|
652
906
|
method,
|
|
653
907
|
headers,
|
|
654
908
|
signal: ac.signal,
|
|
655
|
-
}
|
|
909
|
+
};
|
|
910
|
+
if (keepaliveEligible) {
|
|
911
|
+
fetchInit.keepalive = true;
|
|
912
|
+
}
|
|
913
|
+
const res = await this.fetchImpl(url, fetchInit);
|
|
656
914
|
const latencyMs = Date.now() - tStart;
|
|
915
|
+
const requestId = res.headers.get("x-request-id") ||
|
|
916
|
+
res.headers.get("x-ohm-request-id") ||
|
|
917
|
+
undefined;
|
|
657
918
|
this.onUsage?.({
|
|
658
919
|
endpoint: path,
|
|
659
920
|
method,
|
|
@@ -662,40 +923,78 @@ export class OHMCoreClient {
|
|
|
662
923
|
latencyMs,
|
|
663
924
|
retries: attempt,
|
|
664
925
|
});
|
|
926
|
+
safeHook(this.hooks?.onResponse, {
|
|
927
|
+
method,
|
|
928
|
+
url,
|
|
929
|
+
status: res.status,
|
|
930
|
+
ok: res.ok,
|
|
931
|
+
attempt,
|
|
932
|
+
latencyMs,
|
|
933
|
+
requestId,
|
|
934
|
+
});
|
|
665
935
|
if (res.ok) {
|
|
666
|
-
// 204 no-content tolerance
|
|
667
936
|
if (res.status === 204)
|
|
668
937
|
return undefined;
|
|
669
938
|
return (await res.json());
|
|
670
939
|
}
|
|
671
|
-
if (isRetriableStatus(res.status) && attempt <
|
|
672
|
-
|
|
940
|
+
if (isRetriableStatus(res.status) && attempt < maxRetries) {
|
|
941
|
+
const sleepMs = Math.min(backoffMs(attempt), deadlineAt != null ? Math.max(0, deadlineAt - Date.now() - 50) : Infinity);
|
|
942
|
+
if (deadlineAt != null && sleepMs <= 0) {
|
|
943
|
+
// No headroom for another attempt — fail now rather than
|
|
944
|
+
// sleeping into a guaranteed deadline-exceeded outcome.
|
|
945
|
+
throw new OHMTimeoutError({
|
|
946
|
+
message: `Total request deadline (${totalDeadline}ms) would be exceeded by retry sleep`,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
await sleep(sleepMs);
|
|
673
950
|
continue;
|
|
674
951
|
}
|
|
675
952
|
throw await this.parseError(res);
|
|
676
953
|
}
|
|
677
954
|
catch (err) {
|
|
678
955
|
clearTimeout(t);
|
|
956
|
+
// Hooks first — even for errors we want to surface them to
|
|
957
|
+
// observability before we throw or retry.
|
|
958
|
+
const willRetryDecision = (() => {
|
|
959
|
+
if (err instanceof OHMError)
|
|
960
|
+
return false;
|
|
961
|
+
if (options?.signal?.aborted)
|
|
962
|
+
return false;
|
|
963
|
+
const e = err;
|
|
964
|
+
if (e?.name === "AbortError" || e?.code === "ABORT_ERR")
|
|
965
|
+
return false;
|
|
966
|
+
return attempt < maxRetries;
|
|
967
|
+
})();
|
|
968
|
+
safeHook(this.hooks?.onError, {
|
|
969
|
+
method,
|
|
970
|
+
url,
|
|
971
|
+
attempt,
|
|
972
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
973
|
+
willRetry: willRetryDecision,
|
|
974
|
+
});
|
|
679
975
|
if (err instanceof OHMError)
|
|
680
976
|
throw err;
|
|
681
|
-
// If the caller cancelled, surface that immediately — don't
|
|
682
|
-
// burn retries on a request the user no longer wants.
|
|
683
977
|
if (options?.signal?.aborted) {
|
|
684
978
|
throw new OHMAbortError();
|
|
685
979
|
}
|
|
686
|
-
// Internal AbortController firing without caller cancel = timeout.
|
|
687
|
-
// The fetch impl rejects with an AbortError-shaped DOMException;
|
|
688
|
-
// this is the canonical detection pattern for "client-side
|
|
689
|
-
// deadline exceeded".
|
|
690
980
|
const e = err;
|
|
691
981
|
if (e?.name === "AbortError" || e?.code === "ABORT_ERR") {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
982
|
+
// Distinguish total-deadline timeout from per-attempt timeout
|
|
983
|
+
// in the error message — easier triage in support tickets.
|
|
984
|
+
const msg = deadlineAt != null && Date.now() >= deadlineAt
|
|
985
|
+
? `Total request deadline (${totalDeadline}ms) exceeded`
|
|
986
|
+
: `Request timed out after ${attemptTimeout}ms`;
|
|
987
|
+
throw new OHMTimeoutError({ message: msg });
|
|
695
988
|
}
|
|
696
989
|
lastError = err;
|
|
697
|
-
if (attempt <
|
|
698
|
-
|
|
990
|
+
if (attempt < maxRetries) {
|
|
991
|
+
const sleepMs = Math.min(backoffMs(attempt), deadlineAt != null ? Math.max(0, deadlineAt - Date.now() - 50) : Infinity);
|
|
992
|
+
if (deadlineAt != null && sleepMs <= 0) {
|
|
993
|
+
throw new OHMTimeoutError({
|
|
994
|
+
message: `Total request deadline (${totalDeadline}ms) would be exceeded by retry sleep`,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
await sleep(sleepMs);
|
|
699
998
|
continue;
|
|
700
999
|
}
|
|
701
1000
|
}
|
|
@@ -718,25 +1017,48 @@ export class OHMCoreClient {
|
|
|
718
1017
|
}
|
|
719
1018
|
async parseError(res) {
|
|
720
1019
|
let body = {};
|
|
1020
|
+
let rawBody;
|
|
721
1021
|
try {
|
|
722
|
-
|
|
1022
|
+
const text = await res.text();
|
|
1023
|
+
rawBody = text;
|
|
1024
|
+
if (text) {
|
|
1025
|
+
try {
|
|
1026
|
+
const parsed = JSON.parse(text);
|
|
1027
|
+
body = parsed;
|
|
1028
|
+
rawBody = parsed;
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
// body wasn't JSON; that's fine for the error path
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
723
1034
|
}
|
|
724
1035
|
catch {
|
|
725
|
-
// body wasn't
|
|
1036
|
+
// body wasn't readable; that's fine for the error path
|
|
726
1037
|
}
|
|
1038
|
+
// Capture headers as a plain dict so customers can `console.log` or
|
|
1039
|
+
// forward to their telemetry without dragging a Headers instance.
|
|
1040
|
+
const responseHeaders = {};
|
|
1041
|
+
res.headers.forEach((value, key) => {
|
|
1042
|
+
responseHeaders[key.toLowerCase()] = value;
|
|
1043
|
+
});
|
|
727
1044
|
const requestId = res.headers.get("x-request-id") || res.headers.get("x-ohm-request-id") || undefined;
|
|
728
1045
|
const message = body?.message || `HTTP ${res.status}`;
|
|
1046
|
+
const base = {
|
|
1047
|
+
message,
|
|
1048
|
+
status: res.status,
|
|
1049
|
+
requestId,
|
|
1050
|
+
responseHeaders,
|
|
1051
|
+
responseBody: rawBody,
|
|
1052
|
+
};
|
|
729
1053
|
// 401 / 403 → auth
|
|
730
1054
|
if (res.status === 401 || res.status === 403) {
|
|
731
|
-
return new OHMAuthError(
|
|
1055
|
+
return new OHMAuthError(base);
|
|
732
1056
|
}
|
|
733
1057
|
// 404 → not found (slug, job id, …). Server may include
|
|
734
1058
|
// `availableSlugs` to power a customer-side picker.
|
|
735
1059
|
if (res.status === 404) {
|
|
736
1060
|
return new OHMNotFoundError({
|
|
737
|
-
|
|
738
|
-
status: res.status,
|
|
739
|
-
requestId,
|
|
1061
|
+
...base,
|
|
740
1062
|
availableSlugs: body?.availableSlugs,
|
|
741
1063
|
});
|
|
742
1064
|
}
|
|
@@ -744,9 +1066,7 @@ export class OHMCoreClient {
|
|
|
744
1066
|
// failing JSON-Schema paths.
|
|
745
1067
|
if (res.status === 422 || res.status === 400) {
|
|
746
1068
|
return new OHMValidationError({
|
|
747
|
-
|
|
748
|
-
status: res.status,
|
|
749
|
-
requestId,
|
|
1069
|
+
...base,
|
|
750
1070
|
fields: body?.fields,
|
|
751
1071
|
});
|
|
752
1072
|
}
|
|
@@ -759,17 +1079,13 @@ export class OHMCoreClient {
|
|
|
759
1079
|
// an upgrade-plan modal instead of a "slow down" toast.
|
|
760
1080
|
if (body?.code === "quota_exceeded" || body?.errorCode === "quota_exceeded") {
|
|
761
1081
|
return new OHMQuotaExceededError({
|
|
762
|
-
|
|
763
|
-
status: res.status,
|
|
764
|
-
requestId,
|
|
1082
|
+
...base,
|
|
765
1083
|
resetAt: body?.resetAt,
|
|
766
1084
|
quotaKind: body?.quotaKind,
|
|
767
1085
|
});
|
|
768
1086
|
}
|
|
769
1087
|
return new OHMRateLimitError({
|
|
770
|
-
|
|
771
|
-
status: res.status,
|
|
772
|
-
requestId,
|
|
1088
|
+
...base,
|
|
773
1089
|
retryAfterSec: Number(res.headers.get("retry-after")) ||
|
|
774
1090
|
body?.retryAfterSec ||
|
|
775
1091
|
undefined,
|
|
@@ -778,9 +1094,7 @@ export class OHMCoreClient {
|
|
|
778
1094
|
// 402 → payment required (Stripe convention) → quota.
|
|
779
1095
|
if (res.status === 402) {
|
|
780
1096
|
return new OHMQuotaExceededError({
|
|
781
|
-
|
|
782
|
-
status: res.status,
|
|
783
|
-
requestId,
|
|
1097
|
+
...base,
|
|
784
1098
|
resetAt: body?.resetAt,
|
|
785
1099
|
quotaKind: body?.quotaKind,
|
|
786
1100
|
});
|
|
@@ -788,9 +1102,14 @@ export class OHMCoreClient {
|
|
|
788
1102
|
// 504 / gateway timeout → timeout class so customers can pattern
|
|
789
1103
|
// match for "give it another try" UX.
|
|
790
1104
|
if (res.status === 504 || res.status === 408) {
|
|
791
|
-
return new OHMTimeoutError({
|
|
1105
|
+
return new OHMTimeoutError({
|
|
1106
|
+
message,
|
|
1107
|
+
status: res.status,
|
|
1108
|
+
responseHeaders,
|
|
1109
|
+
responseBody: rawBody,
|
|
1110
|
+
});
|
|
792
1111
|
}
|
|
793
|
-
return new OHMServerError(
|
|
1112
|
+
return new OHMServerError(base);
|
|
794
1113
|
}
|
|
795
1114
|
}
|
|
796
1115
|
function sleep(ms) {
|