@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.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.extract.stream({ apiSlug, file });
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 intervalMs = options.intervalMs ?? 2000;
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, intervalMs));
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
- * Default SSE-based streaming for audio.extract.stream. Subclasses
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"] = "@ohm_studio/sdk-core@0.7.0";
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", "@ohm_studio/sdk-core@0.7.0");
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
- // Idempotency-Key server short-circuits same-key retries within
627
- // 24 h to the cached response. Stripe / Twilio convention.
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 <= this.maxRetries; 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(), this.timeoutMs);
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 res = await this.fetchImpl(url, {
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 < this.maxRetries) {
672
- await sleep(backoffMs(attempt));
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
- throw new OHMTimeoutError({
693
- message: `Request timed out after ${this.timeoutMs}ms`,
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 < this.maxRetries) {
698
- await sleep(backoffMs(attempt));
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
- body = await res.json();
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 JSON; that's fine for the error path
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({ message, status: res.status, requestId });
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
- message,
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
- message,
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
- message,
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
- message,
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
- message,
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({ message, status: res.status });
1105
+ return new OHMTimeoutError({
1106
+ message,
1107
+ status: res.status,
1108
+ responseHeaders,
1109
+ responseBody: rawBody,
1110
+ });
792
1111
  }
793
- return new OHMServerError({ message, status: res.status, requestId });
1112
+ return new OHMServerError(base);
794
1113
  }
795
1114
  }
796
1115
  function sleep(ms) {