@cutoptim/engine 0.0.1 → 1.3.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CutOptim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,5 +1,232 @@
1
- # @cutoptim/engine
1
+ <p align="center">
2
+ <a href="https://cutoptim.com/engine"><img src="https://raw.githubusercontent.com/cutoptim/engine-js/main/.github/logo.png" width="88" height="88" alt="CutOptim"></a>
3
+ </p>
2
4
 
3
- Placeholder release. The official TypeScript / JavaScript client for the
4
- [CutOptim Engine API](https://cutoptim.com/engine/reference) is published as **1.0.0 and later**
5
- from [github.com/cutoptim/engine-js](https://github.com/cutoptim/engine-js).
5
+ <h1 align="center">CutOptim Engine API — TypeScript SDK</h1>
6
+
7
+ <p align="center">
8
+ Cutting optimization over HTTPS: <b>2D panels</b>, <b>1D linear</b>, <b>timber</b> and <b>true-shape nesting</b>.<br>
9
+ Deterministic plans, saw-ready cut sequences — typed end to end.
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@cutoptim/engine"><img alt="npm" src="https://img.shields.io/npm/v/@cutoptim/engine?color=f0c040&labelColor=0d0f14&label=npm"></a>
14
+ <a href="https://github.com/cutoptim/engine-js/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/cutoptim/engine-js/actions/workflows/ci.yml/badge.svg"></a>
15
+ <img alt="Node 18+" src="https://img.shields.io/badge/node-%E2%89%A518-f0c040?labelColor=0d0f14">
16
+ <img alt="Zero dependencies" src="https://img.shields.io/badge/dependencies-0-f0c040?labelColor=0d0f14">
17
+ <a href="https://github.com/cutoptim/engine-js/blob/main/LICENSE"><img alt="MIT" src="https://img.shields.io/badge/license-MIT-f0c040?labelColor=0d0f14"></a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="https://cutoptim.com/engine/reference/quickstart">Quickstart</a> ·
22
+ <a href="https://cutoptim.com/engine/reference">API reference</a> ·
23
+ <a href="https://cutoptim.com/engine/reference/guides/webhooks">Webhooks</a> ·
24
+ <a href="https://cutoptim.com/engine/reference/changelog">Changelog</a> ·
25
+ <a href="https://cutoptim.com/dashboard#api-keys">Get an API key</a>
26
+ </p>
27
+
28
+ ---
29
+
30
+ ## Why this SDK
31
+
32
+ - **Typed from the contract.** Every request and response type is generated from the [OpenAPI 3.1 document](https://cutoptim.com/engine/openapi.json) — your editor knows every field, and its tooltip is the reference text.
33
+ - **Zero dependencies.** The built-in `fetch` and Web Crypto: Node 18+, Deno, Bun, edge runtimes and the browser.
34
+ - **Retries that never cost twice.** Every optimize call carries an `Idempotency-Key`; a retried call returns the stored answer instead of a second charge.
35
+ - **Async jobs without the boilerplate.** `waitForJob()` polls the `max` engine for you — or let a webhook tell you.
36
+ - **Webhook verification built in.** `verifyWebhook()` checks the signature on the raw body, with the replay window and secret rotation handled.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ npm install @cutoptim/engine
42
+ ```
43
+
44
+ A free **test key** (`co_test_…`, never billed) is enough to build the whole integration — create one on your [dashboard](https://cutoptim.com/dashboard#api-keys).
45
+
46
+ ## Quickstart
47
+
48
+ ```ts
49
+ import { CutOptim } from '@cutoptim/engine';
50
+
51
+ const client = new CutOptim({ apiKey: process.env.CUTOPTIM_KEY });
52
+
53
+ const plan = await client.optimize2d({
54
+ parts: [
55
+ { name: 'Side', w: 720, h: 560, qty: 2 },
56
+ { name: 'Shelf', w: 764, h: 540, qty: 3, rotatable: false },
57
+ ],
58
+ stock: [{ w: 2800, h: 2070, price: 48 }],
59
+ options: { kerf: 4 },
60
+ });
61
+
62
+ if (!CutOptim.isJobAccepted(plan)) {
63
+ console.log(`${plan.metrics.sheetCount} sheet(s), ${plan.metrics.yieldPct}% yield`);
64
+ for (const sheet of plan.sheets) {
65
+ for (const p of sheet.parts) console.log(p.name, p.x, p.y, p.w, p.h, p.rotated ? '↻' : '');
66
+ }
67
+ }
68
+ ```
69
+
70
+ The same input always returns the same plan — cache it, test against it, quote from it.
71
+
72
+ ## Every mode
73
+
74
+ ```ts
75
+ // 1D linear — bars, profiles, tubes
76
+ const bars = await client.optimize1d({
77
+ parts: [{ name: 'Rail', length: 1200, qty: 6 }],
78
+ stock: [{ length: 6000 }],
79
+ });
80
+ console.log(bars.metrics.rodCount, bars.metrics.yieldPct);
81
+
82
+ // Timber — lengths cut from beams of a given cross-section (sw × sh)
83
+ const timber = await client.optimizeWood({
84
+ parts: [{ name: 'Joist', sw: 45, sh: 145, length: 3600, qty: 4 }],
85
+ stock: [{ sw: 45, sh: 145, length: 4800 }],
86
+ });
87
+
88
+ // True-shape nesting — polygons (or SVG/DXF sources) on sheets
89
+ const nest = await client.optimizeNest({
90
+ parts: [{ name: 'Bracket', polygon: [[0, 0], [120, 0], [120, 40], [40, 120], [0, 120]], qty: 10 }],
91
+ stock: [{ w: 1000, h: 500 }],
92
+ });
93
+ if (!CutOptim.isJobAccepted(nest)) console.log(nest.metrics.sheetCount, nest.metrics.density);
94
+ ```
95
+
96
+ Check a request without spending a call — the validate endpoints need no key and give the same verdict:
97
+
98
+ ```ts
99
+ const check = await new CutOptim().validate2d(request); // { valid: true, warnings: [...] } or a typed 400
100
+ ```
101
+
102
+ ## Async `max` jobs
103
+
104
+ `engine: "max"` searches harder (seconds to a minute) and answers `202` with a job id instead of a plan:
105
+
106
+ ```ts
107
+ const accepted = await client.optimize2d({ ...request, engine: 'max' });
108
+ if (CutOptim.isJobAccepted(accepted)) {
109
+ const job = await client.waitForJob(accepted.jobId, { timeoutMs: 5 * 60_000 }); // polls GET /v1/jobs/{id}, no quota
110
+ if (job.status === 'succeeded') console.log(job.result);
111
+ }
112
+ ```
113
+
114
+ ## Webhooks
115
+
116
+ Instead of polling, register an endpoint on your [dashboard](https://cutoptim.com/dashboard#api-webhooks) and receive a signed `POST` (`job.succeeded` / `job.failed`) when a `max` job finishes. Verify it on the **raw** body:
117
+
118
+ ```ts
119
+ // Express
120
+ import express from 'express';
121
+ import { verifyWebhook } from '@cutoptim/engine';
122
+
123
+ app.post('/cutoptim/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
124
+ const ok = await verifyWebhook(req.body, req.get('CutOptim-Signature'), process.env.CUTOPTIM_WEBHOOK_SECRET!);
125
+ if (!ok) return res.sendStatus(400);
126
+ res.sendStatus(204); // answer within 10 s, then do the work
127
+ const event = JSON.parse(req.body.toString('utf8'));
128
+ // deduplicate on event.id — delivery is at least once
129
+ });
130
+ ```
131
+
132
+ ```ts
133
+ // Next.js route handler / any Web-standard runtime
134
+ export async function POST(request: Request) {
135
+ const raw = await request.text();
136
+ if (!(await verifyWebhook(raw, request.headers.get('CutOptim-Signature'), process.env.CUTOPTIM_WEBHOOK_SECRET!))) {
137
+ return new Response(null, { status: 400 });
138
+ }
139
+ const event = JSON.parse(raw); // { id, type, created, env, data: { jobId, mode, engine, status } }
140
+ return new Response(null, { status: 204 });
141
+ }
142
+ ```
143
+
144
+ During a secret rotation both secrets sign for 24 hours — pass both: `verifyWebhook(raw, header, [newSecret, oldSecret])`.
145
+
146
+ ## Retries and idempotency
147
+
148
+ | Situation | What the client does |
149
+ |---|---|
150
+ | `429 busy`, `503`, network error | retries (2× by default), waiting `Retry-After` when the API sends it |
151
+ | `409 idempotency_in_progress` | retries — the first request is still running; the retry receives its answer |
152
+ | any optimize call | sends an `Idempotency-Key` (a fresh UUID, or yours) — the **same** key on every retry |
153
+
154
+ Pass your own key to make retries safe across process restarts too:
155
+
156
+ ```ts
157
+ await client.optimizeNest(request, { idempotencyKey: `order-${orderId}` });
158
+ ```
159
+
160
+ Within 24 hours the same key and request return the stored answer — not charged again, and a `max` job is not queued twice.
161
+
162
+ ## Errors
163
+
164
+ Any non-2xx answer throws a `CutOptimError`:
165
+
166
+ ```ts
167
+ import { CutOptimError } from '@cutoptim/engine';
168
+
169
+ try {
170
+ await client.optimize2d(request);
171
+ } catch (e) {
172
+ if (e instanceof CutOptimError) {
173
+ console.log(e.status, e.code, e.details, e.retryAfter); // branch on e.code, never on e.message
174
+ }
175
+ }
176
+ ```
177
+
178
+ | `code` | Status | Meaning |
179
+ |---|---|---|
180
+ | `invalid_request` | 400 | the request is wrong — `details.path` names the field; never charged |
181
+ | `unauthorized` | 401 | missing or unknown API key |
182
+ | `quota_exceeded` | 402 | the account's monthly quota is used up — `retryAfter` counts to the reset |
183
+ | `key_revoked` | 403 | the key is revoked, or the API subscription is not active |
184
+ | `idempotency_conflict` | 409 | the key was used for a different request — use a new key |
185
+ | `too_large` | 413 | an input cap was exceeded (parts, rows, body size) |
186
+ | `busy` | 429 | at capacity right now — retried automatically |
187
+ | `service_unavailable` | 503 | an engine is not available on this deployment |
188
+ | `solve_timeout` | 504 | the nesting solve ran out of time |
189
+ | `timeout` | — | `waitForJob()` gave up waiting (the job keeps running) |
190
+
191
+ The full list, with what to do for each, is on the [errors page](https://cutoptim.com/engine/reference/errors).
192
+
193
+ ## Configuration
194
+
195
+ ```ts
196
+ new CutOptim({
197
+ apiKey: 'co_live_…', // not needed for health() and validate*()
198
+ baseUrl: 'https://api.cutoptim.com', // default
199
+ maxRetries: 2, // 429 / 503 / 409 in-progress / network errors
200
+ timeoutMs: 30_000, // per request
201
+ fetch: customFetch, // optional: your own fetch implementation
202
+ });
203
+ ```
204
+
205
+ ## API coverage
206
+
207
+ | Method | Endpoint | Quota |
208
+ |---|---|---|
209
+ | `optimize2d(req, opts?)` | `POST /v1/optimize/2d` | 1 call |
210
+ | `optimize1d(req, opts?)` | `POST /v1/optimize/1d` | 1 call |
211
+ | `optimizeWood(req, opts?)` | `POST /v1/optimize/wood` | 1 call |
212
+ | `optimizeNest(req, opts?)` | `POST /v1/optimize/nest` | 1 call |
213
+ | `validate2d` · `validate1d` · `validateWood` · `validateNest` | `POST /v1/validate/*` | free, no key |
214
+ | `importNest(req)` | `POST /v1/import/nest` | free (key required) |
215
+ | `getJob(id)` · `waitForJob(id)` | `GET /v1/jobs/{id}` | free |
216
+ | `usage()` | `GET /v1/usage` | free |
217
+ | `health()` | `GET /v1/health` | free, no key |
218
+
219
+ ## Versioning
220
+
221
+ The SDK follows semver. The API is `v1` and only grows within it: a new field, endpoint or option arrives as a **minor** SDK release with the regenerated types; nothing you use today is removed or narrowed. See [versioning and stability](https://cutoptim.com/engine/reference/versioning) and the [API changelog](https://cutoptim.com/engine/reference/changelog) (also as RSS).
222
+
223
+ ## Support
224
+
225
+ - Questions and bugs: [GitHub issues](https://github.com/cutoptim/engine-js/issues) or [support@cutoptim.com](mailto:support@cutoptim.com)
226
+ - Limits and quotas: [cutoptim.com/engine/reference/limits](https://cutoptim.com/engine/reference/limits)
227
+
228
+ This repository is published from the CutOptim monorepo; pull requests are welcome and applied upstream.
229
+
230
+ ## License
231
+
232
+ [MIT](https://github.com/cutoptim/engine-js/blob/main/LICENSE) © CutOptim
@@ -0,0 +1,78 @@
1
+ import type * as T from './types.gen.js';
2
+ export type * from './types.gen.js';
3
+ export { verifyWebhook, SIGNATURE_HEADER } from './webhooks.js';
4
+ export { VERSION } from './version.js';
5
+ export interface CutOptimOptions {
6
+ /** Your API key (co_live_… or co_test_…). Not needed for health() and the validate*() calls. */
7
+ apiKey?: string;
8
+ /** Default https://api.cutoptim.com */
9
+ baseUrl?: string;
10
+ /** Retries on 429 / 503 / 409 in-progress / network errors (default 2). */
11
+ maxRetries?: number;
12
+ /** Per-request timeout in milliseconds (default 30 000). */
13
+ timeoutMs?: number;
14
+ /** A fetch implementation (default: the global fetch). */
15
+ fetch?: typeof fetch;
16
+ }
17
+ export interface CallOptions {
18
+ /** Your own Idempotency-Key for this job (8–128 of A-Z a-z 0-9 _ -). Default: a fresh UUID per call. */
19
+ idempotencyKey?: string;
20
+ signal?: AbortSignal;
21
+ }
22
+ export interface JobAccepted {
23
+ jobId: string;
24
+ status: 'queued';
25
+ mode: string;
26
+ engine: 'max';
27
+ pollAfterMs: number;
28
+ quota?: {
29
+ reserved?: number;
30
+ used?: number;
31
+ limit?: number;
32
+ };
33
+ }
34
+ export type JobStatus = T.GetJobResult;
35
+ /** A non-2xx answer from the API. `code` is the stable machine-readable error (`invalid_request`, `quota_exceeded`, …). */
36
+ export declare class CutOptimError extends Error {
37
+ readonly status: number;
38
+ readonly code: string;
39
+ readonly details: unknown;
40
+ readonly retryAfter: number | null;
41
+ constructor(status: number, code: string, message: string, details: unknown, retryAfter: number | null);
42
+ }
43
+ export declare class CutOptim {
44
+ private readonly apiKey?;
45
+ private readonly baseUrl;
46
+ private readonly maxRetries;
47
+ private readonly timeoutMs;
48
+ private readonly fetchImpl;
49
+ constructor(opts?: CutOptimOptions);
50
+ /** 2D panels. engine:"max" answers with a JobAccepted (202) — see waitForJob. */
51
+ optimize2d(request: T.Optimize2dBody, opts?: CallOptions): Promise<T.Optimize2DResponse | JobAccepted>;
52
+ /** 1D linear cutting. */
53
+ optimize1d(request: T.Optimize1dBody, opts?: CallOptions): Promise<T.Optimize1DResponse>;
54
+ /** Timber with cross-sections. */
55
+ optimizeWood(request: T.OptimizeWoodRequest, opts?: CallOptions): Promise<T.OptimizeWoodResponse>;
56
+ /** True-shape nesting. engine:"max" answers with a JobAccepted (202). */
57
+ optimizeNest(request: T.OptimizeNestRequest, opts?: CallOptions): Promise<T.OptimizeNestResponse | JobAccepted>;
58
+ validate2d(request: T.Optimize2dBody, opts?: CallOptions): Promise<T.ValidateResponse>;
59
+ validate1d(request: T.Optimize1dBody, opts?: CallOptions): Promise<T.ValidateResponse>;
60
+ validateWood(request: T.OptimizeWoodRequest, opts?: CallOptions): Promise<T.ValidateResponse>;
61
+ validateNest(request: T.OptimizeNestRequest, opts?: CallOptions): Promise<T.ValidateResponse>;
62
+ /** Read the parts out of an SVG or DXF file (needs a key, costs no quota). */
63
+ importNest(request: T.NestImportRequest, opts?: CallOptions): Promise<T.NestImportResponse>;
64
+ getJob(jobId: string, opts?: CallOptions): Promise<JobStatus>;
65
+ usage(opts?: CallOptions): Promise<T.UsageResponse>;
66
+ health(opts?: CallOptions): Promise<T.HealthResponse>;
67
+ /** True when an optimize answer is the async 202 of engine:"max". */
68
+ static isJobAccepted(r: unknown): r is JobAccepted;
69
+ /** Poll GET /v1/jobs/{id} (no quota) until the job succeeded or failed. Honours pollAfterMs; default timeout 5 minutes. */
70
+ waitForJob(jobId: string, opts?: {
71
+ timeoutMs?: number;
72
+ intervalMs?: number;
73
+ signal?: AbortSignal;
74
+ }): Promise<JobStatus>;
75
+ private call;
76
+ private backoff;
77
+ }
78
+ export default CutOptim;
package/dist/index.js ADDED
@@ -0,0 +1,127 @@
1
+ import { VERSION } from './version.js';
2
+ export { verifyWebhook, SIGNATURE_HEADER } from './webhooks.js';
3
+ export { VERSION } from './version.js';
4
+ /** A non-2xx answer from the API. `code` is the stable machine-readable error (`invalid_request`, `quota_exceeded`, …). */
5
+ export class CutOptimError extends Error {
6
+ status;
7
+ code;
8
+ details;
9
+ retryAfter;
10
+ constructor(status, code, message, details, retryAfter) {
11
+ super(message);
12
+ this.name = 'CutOptimError';
13
+ this.status = status;
14
+ this.code = code;
15
+ this.details = details;
16
+ this.retryAfter = retryAfter;
17
+ }
18
+ }
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+ const newKey = () => globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
21
+ export class CutOptim {
22
+ apiKey;
23
+ baseUrl;
24
+ maxRetries;
25
+ timeoutMs;
26
+ fetchImpl;
27
+ constructor(opts = {}) {
28
+ this.apiKey = opts.apiKey;
29
+ this.baseUrl = (opts.baseUrl ?? 'https://api.cutoptim.com').replace(/\/+$/, '');
30
+ this.maxRetries = opts.maxRetries ?? 2;
31
+ this.timeoutMs = opts.timeoutMs ?? 30_000;
32
+ const f = opts.fetch ?? globalThis.fetch;
33
+ if (!f)
34
+ throw new Error('CutOptim: no fetch available — pass options.fetch (Node 18+ has one built in)');
35
+ this.fetchImpl = f.bind(globalThis);
36
+ }
37
+ // ── optimize (metered) ──
38
+ /** 2D panels. engine:"max" answers with a JobAccepted (202) — see waitForJob. */
39
+ optimize2d(request, opts) { return this.call('POST', '/v1/optimize/2d', request, opts, true); }
40
+ /** 1D linear cutting. */
41
+ optimize1d(request, opts) { return this.call('POST', '/v1/optimize/1d', request, opts, true); }
42
+ /** Timber with cross-sections. */
43
+ optimizeWood(request, opts) { return this.call('POST', '/v1/optimize/wood', request, opts, true); }
44
+ /** True-shape nesting. engine:"max" answers with a JobAccepted (202). */
45
+ optimizeNest(request, opts) { return this.call('POST', '/v1/optimize/nest', request, opts, true); }
46
+ // ── free / account ──
47
+ validate2d(request, opts) { return this.call('POST', '/v1/validate/2d', request, opts); }
48
+ validate1d(request, opts) { return this.call('POST', '/v1/validate/1d', request, opts); }
49
+ validateWood(request, opts) { return this.call('POST', '/v1/validate/wood', request, opts); }
50
+ validateNest(request, opts) { return this.call('POST', '/v1/validate/nest', request, opts); }
51
+ /** Read the parts out of an SVG or DXF file (needs a key, costs no quota). */
52
+ importNest(request, opts) { return this.call('POST', '/v1/import/nest', request, opts); }
53
+ getJob(jobId, opts) { return this.call('GET', `/v1/jobs/${encodeURIComponent(jobId)}`, undefined, opts); }
54
+ usage(opts) { return this.call('GET', '/v1/usage', undefined, opts); }
55
+ health(opts) { return this.call('GET', '/v1/health', undefined, opts); }
56
+ /** True when an optimize answer is the async 202 of engine:"max". */
57
+ static isJobAccepted(r) {
58
+ return !!r && typeof r === 'object' && typeof r.jobId === 'string' && r.status === 'queued';
59
+ }
60
+ /** Poll GET /v1/jobs/{id} (no quota) until the job succeeded or failed. Honours pollAfterMs; default timeout 5 minutes. */
61
+ async waitForJob(jobId, opts = {}) {
62
+ const deadline = Date.now() + (opts.timeoutMs ?? 300_000);
63
+ let wait = opts.intervalMs ?? 2000;
64
+ for (;;) {
65
+ const j = await this.getJob(jobId, { signal: opts.signal });
66
+ if (j.status === 'succeeded' || j.status === 'failed')
67
+ return j;
68
+ if (Date.now() + wait > deadline)
69
+ throw new CutOptimError(0, 'timeout', `job ${jobId} is still ${j.status} after the wait timeout`, null, null);
70
+ await sleep(wait);
71
+ wait = Math.min(wait * 1.5, 10_000);
72
+ }
73
+ }
74
+ async call(method, path, body, opts = {}, idempotent = false) {
75
+ const headers = { Accept: 'application/json', 'User-Agent': `cutoptim-engine-js/${VERSION}` };
76
+ if (this.apiKey)
77
+ headers.Authorization = `Bearer ${this.apiKey}`;
78
+ if (body !== undefined)
79
+ headers['Content-Type'] = 'application/json';
80
+ // One key for the whole call, retries included: a retried optimize returns the stored answer, never a second charge.
81
+ if (idempotent)
82
+ headers['Idempotency-Key'] = opts.idempotencyKey ?? newKey();
83
+ const payload = body === undefined ? undefined : JSON.stringify(body);
84
+ for (let attempt = 0;; attempt++) {
85
+ const ctrl = new AbortController();
86
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
87
+ const onAbort = () => ctrl.abort();
88
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
89
+ let res;
90
+ try {
91
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, { method, headers, body: payload, signal: ctrl.signal });
92
+ }
93
+ catch (e) {
94
+ if (opts.signal?.aborted || attempt >= this.maxRetries)
95
+ throw e;
96
+ await sleep(this.backoff(attempt, null));
97
+ continue;
98
+ }
99
+ finally {
100
+ clearTimeout(timer);
101
+ opts.signal?.removeEventListener('abort', onAbort);
102
+ }
103
+ const text = await res.text();
104
+ let json = null;
105
+ try {
106
+ json = text ? JSON.parse(text) : null;
107
+ }
108
+ catch { /* a proxy's 504 page is not JSON */ }
109
+ if (res.ok)
110
+ return json;
111
+ const retryAfter = Number(res.headers.get('Retry-After')) || null;
112
+ const code = typeof json?.error === 'string' ? json.error : `http_${res.status}`;
113
+ const retryable = res.status === 429 || res.status === 503 || (res.status === 409 && code === 'idempotency_in_progress');
114
+ if (retryable && attempt < this.maxRetries) {
115
+ await sleep(this.backoff(attempt, retryAfter));
116
+ continue;
117
+ }
118
+ throw new CutOptimError(res.status, code, typeof json?.message === 'string' ? json.message : `HTTP ${res.status}`, json?.details ?? null, retryAfter);
119
+ }
120
+ }
121
+ backoff(attempt, retryAfterS) {
122
+ if (retryAfterS)
123
+ return Math.min(retryAfterS, 60) * 1000;
124
+ return Math.min(500 * 2 ** attempt, 8000);
125
+ }
126
+ }
127
+ export default CutOptim;