@mappa-ai/mappa-node 1.2.2 → 1.2.4

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/index.cjs CHANGED
@@ -170,6 +170,54 @@ var JobCanceledError = class extends MappaError {
170
170
  return lines.join("\n");
171
171
  }
172
172
  };
173
+ /**
174
+ * Error thrown when SSE streaming fails after all retries.
175
+ *
176
+ * Includes recovery metadata to allow callers to resume or retry:
177
+ * - `jobId`: The job being streamed (when known)
178
+ * - `lastEventId`: Last successfully received event ID for resumption
179
+ * - `url`: The stream URL that failed
180
+ * - `retryCount`: Number of retries attempted
181
+ *
182
+ * @example
183
+ * ```typescript
184
+ * try {
185
+ * for await (const event of mappa.jobs.stream(jobId)) { ... }
186
+ * } catch (err) {
187
+ * if (err instanceof StreamError) {
188
+ * console.log(`Stream failed for job ${err.jobId}`);
189
+ * console.log(`Last event ID: ${err.lastEventId}`);
190
+ * // Can retry with: mappa.jobs.stream(err.jobId)
191
+ * }
192
+ * }
193
+ * ```
194
+ */
195
+ var StreamError = class extends MappaError {
196
+ name = "StreamError";
197
+ jobId;
198
+ lastEventId;
199
+ url;
200
+ retryCount;
201
+ constructor(message, opts) {
202
+ super(message, {
203
+ requestId: opts.requestId,
204
+ cause: opts.cause
205
+ });
206
+ this.jobId = opts.jobId;
207
+ this.lastEventId = opts.lastEventId;
208
+ this.url = opts.url;
209
+ this.retryCount = opts.retryCount;
210
+ }
211
+ toString() {
212
+ const lines = [`${this.name}: ${this.message}`];
213
+ if (this.jobId) lines.push(` Job ID: ${this.jobId}`);
214
+ if (this.lastEventId) lines.push(` Last Event ID: ${this.lastEventId}`);
215
+ if (this.url) lines.push(` URL: ${this.url}`);
216
+ lines.push(` Retry Count: ${this.retryCount}`);
217
+ if (this.requestId) lines.push(` Request ID: ${this.requestId}`);
218
+ return lines.join("\n");
219
+ }
220
+ };
173
221
 
174
222
  //#endregion
175
223
  //#region src/resources/credits.ts
@@ -885,10 +933,19 @@ var JobsResource = class {
885
933
  } catch (error) {
886
934
  if (opts?.signal?.aborted) throw error;
887
935
  retries++;
888
- if (retries >= maxRetries) throw error;
936
+ if (retries >= maxRetries) throw new StreamError(`Stream connection failed for job ${jobId} after ${maxRetries} retries`, {
937
+ jobId,
938
+ lastEventId,
939
+ retryCount: retries,
940
+ cause: error
941
+ });
889
942
  await this.backoff(retries);
890
943
  }
891
- throw new MappaError(`Failed to get status for job ${jobId} after ${maxRetries} retries`);
944
+ throw new StreamError(`Failed to get status for job ${jobId} after ${maxRetries} retries`, {
945
+ jobId,
946
+ lastEventId,
947
+ retryCount: maxRetries
948
+ });
892
949
  }
893
950
  /**
894
951
  * Map an SSE event to a JobEvent.
@@ -1270,6 +1327,25 @@ function shouldRetry(opts, err) {
1270
1327
  if (err instanceof TypeError) return { retry: true };
1271
1328
  return { retry: false };
1272
1329
  }
1330
+ /**
1331
+ * Checks if an error is a network-level failure that's safe to retry.
1332
+ * Includes socket failures, DNS errors, and fetch TypeErrors.
1333
+ */
1334
+ function isNetworkError(err) {
1335
+ if (err instanceof TypeError) return true;
1336
+ if (err && typeof err === "object") {
1337
+ const code = err.code;
1338
+ if (typeof code === "string") return [
1339
+ "FailedToOpenSocket",
1340
+ "ECONNREFUSED",
1341
+ "ECONNRESET",
1342
+ "ETIMEDOUT",
1343
+ "ENOTFOUND",
1344
+ "EAI_AGAIN"
1345
+ ].includes(code);
1346
+ }
1347
+ return false;
1348
+ }
1273
1349
  var Transport = class {
1274
1350
  fetchImpl;
1275
1351
  constructor(opts) {
@@ -1281,10 +1357,14 @@ var Transport = class {
1281
1357
  *
1282
1358
  * Uses native `fetch` with streaming response body (not browser-only `EventSource`).
1283
1359
  * Parses SSE format manually from the `ReadableStream`.
1360
+ *
1361
+ * Automatically retries on network failures (socket errors, DNS failures, etc.)
1362
+ * up to `maxRetries` times with exponential backoff.
1284
1363
  */
1285
1364
  async *streamSSE(path, opts) {
1286
1365
  const url = buildUrl(this.opts.baseUrl, path);
1287
1366
  const requestId = randomId("req");
1367
+ const maxRetries = Math.max(0, this.opts.maxRetries);
1288
1368
  const headers = {
1289
1369
  Accept: "text/event-stream",
1290
1370
  "Cache-Control": "no-cache",
@@ -1294,57 +1374,74 @@ var Transport = class {
1294
1374
  ...this.opts.defaultHeaders ?? {}
1295
1375
  };
1296
1376
  if (opts?.lastEventId) headers["Last-Event-ID"] = opts.lastEventId;
1297
- const controller = new AbortController();
1298
- const timeout = setTimeout(() => controller.abort(makeAbortError()), this.opts.timeoutMs);
1299
- if (hasAbortSignal(opts?.signal)) {
1300
- const signal = opts?.signal;
1301
- if (signal?.aborted) {
1302
- clearTimeout(timeout);
1303
- throw makeAbortError();
1304
- }
1305
- signal?.addEventListener("abort", () => controller.abort(makeAbortError()), { once: true });
1306
- }
1307
- this.opts.telemetry?.onRequest?.({
1308
- method: "GET",
1309
- url,
1310
- requestId
1311
- });
1312
1377
  let res;
1313
- try {
1314
- res = await this.fetchImpl(url, {
1378
+ let lastError;
1379
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1380
+ const controller = new AbortController();
1381
+ const timeout = setTimeout(() => controller.abort(makeAbortError()), this.opts.timeoutMs);
1382
+ if (hasAbortSignal(opts?.signal)) {
1383
+ const signal = opts?.signal;
1384
+ if (signal?.aborted) {
1385
+ clearTimeout(timeout);
1386
+ throw makeAbortError();
1387
+ }
1388
+ signal?.addEventListener("abort", () => controller.abort(makeAbortError()), { once: true });
1389
+ }
1390
+ this.opts.telemetry?.onRequest?.({
1315
1391
  method: "GET",
1316
- headers,
1317
- signal: controller.signal
1318
- });
1319
- } catch (err) {
1320
- clearTimeout(timeout);
1321
- this.opts.telemetry?.onError?.({
1322
- url,
1323
- requestId,
1324
- error: err
1325
- });
1326
- throw err;
1327
- }
1328
- if (!res.ok) {
1329
- clearTimeout(timeout);
1330
- const { parsed } = await readBody(res);
1331
- const apiErr = coerceApiError(res, parsed);
1332
- this.opts.telemetry?.onError?.({
1333
1392
  url,
1334
- requestId,
1335
- error: apiErr
1393
+ requestId
1336
1394
  });
1337
- throw apiErr;
1338
- }
1339
- if (!res.body) {
1340
- clearTimeout(timeout);
1341
- throw new MappaError("SSE response has no body");
1342
- }
1343
- try {
1344
- yield* this.parseSSEStream(res.body);
1345
- } finally {
1346
- clearTimeout(timeout);
1395
+ try {
1396
+ res = await this.fetchImpl(url, {
1397
+ method: "GET",
1398
+ headers,
1399
+ signal: controller.signal
1400
+ });
1401
+ clearTimeout(timeout);
1402
+ if (!res.ok) {
1403
+ const { parsed } = await readBody(res);
1404
+ const apiErr = coerceApiError(res, parsed);
1405
+ this.opts.telemetry?.onError?.({
1406
+ url,
1407
+ requestId,
1408
+ error: apiErr,
1409
+ context: {
1410
+ attempt,
1411
+ lastEventId: opts?.lastEventId
1412
+ }
1413
+ });
1414
+ if (res.status >= 500 && res.status <= 599 && attempt < maxRetries) {
1415
+ const delay = jitter(backoffMs(attempt + 1, 500, 4e3));
1416
+ await new Promise((r) => setTimeout(r, delay));
1417
+ continue;
1418
+ }
1419
+ throw apiErr;
1420
+ }
1421
+ break;
1422
+ } catch (err) {
1423
+ clearTimeout(timeout);
1424
+ lastError = err;
1425
+ this.opts.telemetry?.onError?.({
1426
+ url,
1427
+ requestId,
1428
+ error: err,
1429
+ context: {
1430
+ attempt,
1431
+ lastEventId: opts?.lastEventId
1432
+ }
1433
+ });
1434
+ if (isNetworkError(err) && attempt < maxRetries) {
1435
+ const delay = jitter(backoffMs(attempt + 1, 500, 4e3));
1436
+ await new Promise((r) => setTimeout(r, delay));
1437
+ continue;
1438
+ }
1439
+ throw err;
1440
+ }
1347
1441
  }
1442
+ if (!res) throw lastError;
1443
+ if (!res.body) throw new MappaError("SSE response has no body");
1444
+ yield* this.parseSSEStream(res.body);
1348
1445
  }
1349
1446
  /**
1350
1447
  * Parse SSE events from a ReadableStream.
@@ -1517,7 +1614,7 @@ function isObject(v) {
1517
1614
  }
1518
1615
  /**
1519
1616
  * Async signature verification using WebCrypto (works in modern Node and browsers).
1520
- * Signature scheme placeholder:
1617
+ * Signature scheme:
1521
1618
  * headers["mappa-signature"] = "t=1700000000,v1=<hex_hmac_sha256>"
1522
1619
  * Signed payload: `${t}.${rawBody}`
1523
1620
  */
@@ -1535,20 +1632,24 @@ var WebhooksResource = class {
1535
1632
  if (!timingSafeEqualHex(await hmacHex(params.secret, signed), parts.v1)) throw new Error("Invalid signature");
1536
1633
  return { ok: true };
1537
1634
  }
1635
+ /**
1636
+ * Parse and validate a webhook event payload.
1637
+ *
1638
+ * @param payload - Raw JSON string from the webhook request body
1639
+ * @returns Parsed and validated webhook event
1640
+ * @throws Error if payload is invalid or missing required fields
1641
+ */
1538
1642
  parseEvent(payload) {
1539
1643
  const raw = JSON.parse(payload);
1540
1644
  if (!isObject(raw)) throw new Error("Invalid webhook payload: not an object");
1541
1645
  const obj = raw;
1542
- const id = obj.id;
1543
1646
  const type = obj.type;
1544
- const createdAt = obj.createdAt;
1545
- if (typeof id !== "string") throw new Error("Invalid webhook payload: id must be a string");
1647
+ const timestamp = obj.timestamp;
1546
1648
  if (typeof type !== "string") throw new Error("Invalid webhook payload: type must be a string");
1547
- if (typeof createdAt !== "string") throw new Error("Invalid webhook payload: createdAt must be a string");
1649
+ if (typeof timestamp !== "string") throw new Error("Invalid webhook payload: timestamp must be a string");
1548
1650
  return {
1549
- id,
1550
1651
  type,
1551
- createdAt,
1652
+ timestamp,
1552
1653
  data: "data" in obj ? obj.data : void 0
1553
1654
  };
1554
1655
  }
@@ -1711,6 +1812,28 @@ function isMappaError(err) {
1711
1812
  function isInsufficientCreditsError(err) {
1712
1813
  return err instanceof InsufficientCreditsError;
1713
1814
  }
1815
+ /**
1816
+ * Type guard for stream connection errors.
1817
+ *
1818
+ * Use this to detect streaming failures and access recovery metadata
1819
+ * like `jobId`, `lastEventId`, and `retryCount`.
1820
+ *
1821
+ * @example
1822
+ * ```typescript
1823
+ * try {
1824
+ * await mappa.reports.generate({ ... });
1825
+ * } catch (err) {
1826
+ * if (isStreamError(err)) {
1827
+ * console.log(`Stream failed for job ${err.jobId}`);
1828
+ * console.log(`Last event ID: ${err.lastEventId}`);
1829
+ * console.log(`Retries attempted: ${err.retryCount}`);
1830
+ * }
1831
+ * }
1832
+ * ```
1833
+ */
1834
+ function isStreamError(err) {
1835
+ return err instanceof StreamError;
1836
+ }
1714
1837
 
1715
1838
  //#endregion
1716
1839
  exports.ApiError = ApiError;
@@ -1721,6 +1844,7 @@ exports.JobFailedError = JobFailedError;
1721
1844
  exports.Mappa = Mappa;
1722
1845
  exports.MappaError = MappaError;
1723
1846
  exports.RateLimitError = RateLimitError;
1847
+ exports.StreamError = StreamError;
1724
1848
  exports.ValidationError = ValidationError;
1725
1849
  exports.hasEntity = hasEntity;
1726
1850
  exports.isInsufficientCreditsError = isInsufficientCreditsError;
@@ -1728,5 +1852,6 @@ exports.isJsonReport = isJsonReport;
1728
1852
  exports.isMappaError = isMappaError;
1729
1853
  exports.isMarkdownReport = isMarkdownReport;
1730
1854
  exports.isPdfReport = isPdfReport;
1855
+ exports.isStreamError = isStreamError;
1731
1856
  exports.isUrlReport = isUrlReport;
1732
1857
  //# sourceMappingURL=index.cjs.map