image2ppt 0.5.3 → 0.6.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
@@ -10,10 +10,11 @@ import { Readable } from "node:stream";
10
10
  // watchdog below is not one of those waits — it is an internal deadline that
11
11
  // never sleeps the caller — so it must stay out of that reckoning.
12
12
  import { clearTimeout as clearTimer, setTimeout as setTimer } from "node:timers";
13
- import { APIConnectionError, APITimeoutError, Image2PPTError, Image2PPTTimeoutError, InvalidFileError, JobCancelledError, JobFailedError, MalformedResponseError, RateLimitedError, exceptionFor, } from "./errors.js";
13
+ import { APIConnectionError, APITimeoutError, IdempotencyKeyInProgressError, IdempotencyKeyMismatchError, Image2PPTError, Image2PPTTimeoutError, InvalidFileError, InvalidIdempotencyKeyError, InvalidParameterError, JobCancelledError, JobFailedError, MalformedResponseError, RateLimitedError, ServerError, exceptionFor, } from "./errors.js";
14
14
  import { compressImageForUpload } from "./compress.js";
15
- import { checkFileSize, checkSubmission, planBatches } from "./limits.js";
16
- import { Job, parseCancellationResult } from "./types.js";
15
+ import { checkFileSize, checkPageSelection, checkSubmission, planBatches } from "./limits.js";
16
+ import { isBlank, normalizePages, parsePages } from "./pages.js";
17
+ import { Job, JobList, parseCancellationResult, wholeNumber } from "./types.js";
17
18
  import { VERSION } from "./version.js";
18
19
  export const DEFAULT_BASE_URL = "https://image2ppt.com";
19
20
  /**
@@ -36,6 +37,93 @@ const RATE_LIMIT_FALLBACK_WAIT_MS = 5_000;
36
37
  * a retry a retry instead of a flood, and costs nothing when the server means it.
37
38
  */
38
39
  const MIN_RETRY_AFTER_SECONDS = 1;
40
+ /**
41
+ * Waits before resending a submission whose outcome is unknown — a dropped
42
+ * connection, a timeout, a 5xx. One entry per extra attempt, so two retries.
43
+ *
44
+ * Resending is safe only because every attempt carries the same `Idempotency-Key`:
45
+ * if an earlier attempt did create the job, the service answers with that job
46
+ * instead of creating a second one. The count stays small because each attempt can
47
+ * re-upload up to 90MB.
48
+ */
49
+ const SUBMIT_RETRY_DELAYS_MS = [1_000, 2_000];
50
+ /**
51
+ * Bounds on waiting out `IDEMPOTENCY_KEY_IN_PROGRESS` — an earlier attempt with the
52
+ * same key that the service is still working on (typically one this client gave up
53
+ * on after its idle timeout). Bounded by attempts as well as time for the reason
54
+ * `MAX_BATCH_ATTEMPTS` gives: every attempt re-sends the whole body. The wait starts
55
+ * at `Retry-After` and grows by half each time, up to the cap.
56
+ */
57
+ const IN_PROGRESS_MAX_ATTEMPTS = 10;
58
+ const IN_PROGRESS_MAX_WAIT_MS = 180_000;
59
+ const IN_PROGRESS_WAIT_CAP_MS = 30_000;
60
+ /**
61
+ * Floor for the idle timeout of a submission by URL. The service may spend up to 120
62
+ * seconds downloading before it answers at all, and nothing moves on the connection
63
+ * meanwhile, so the client's usual 60 would give up on requests that are going fine.
64
+ */
65
+ const URL_SUBMIT_MIN_TIMEOUT_MS = 180_000;
66
+ /** Most links one submission by URL may carry. */
67
+ const MAX_URLS = 50;
68
+ /** What `Idempotency-Key` may contain: 1–255 printable ASCII characters. */
69
+ const IDEMPOTENCY_KEY = /^[\x21-\x7e]{1,255}$/;
70
+ /** The caller's key, checked, or a fresh random one when they gave none. */
71
+ function resolveIdempotencyKey(key) {
72
+ if (key === undefined || key === null)
73
+ return randomUUID();
74
+ if (typeof key !== "string" || !IDEMPOTENCY_KEY.test(key)) {
75
+ throw new InvalidIdempotencyKeyError("idempotencyKey must be 1-255 printable ASCII characters (no spaces)", { code: "INVALID_IDEMPOTENCY_KEY" });
76
+ }
77
+ return key;
78
+ }
79
+ /**
80
+ * `value` as an array of strings, refusing a bare string.
81
+ *
82
+ * A string is iterable, so `submit("deck.pdf")` from plain JavaScript would
83
+ * otherwise be read as one file per character.
84
+ */
85
+ /** How long to wait before retrying a 429: `Retry-After`, else a fixed wait. */
86
+ function rateLimitDelayMs(err) {
87
+ return err.retryAfter != null ? err.retryAfter * 1000 : RATE_LIMIT_FALLBACK_WAIT_MS;
88
+ }
89
+ function stringList(value, name) {
90
+ if (!Array.isArray(value)) {
91
+ throw new TypeError(`${name} must be an array of strings, not ${typeof value}`);
92
+ }
93
+ for (const item of value) {
94
+ if (typeof item !== "string") {
95
+ throw new TypeError(`${name} must hold strings, not ${typeof item}`);
96
+ }
97
+ }
98
+ return value;
99
+ }
100
+ /**
101
+ * The optional fields both kinds of submission carry, leaving out unset ones.
102
+ *
103
+ * An empty or all-whitespace `pages` or `callbackUrl` means "not given" to the
104
+ * service, so it is not sent at all.
105
+ */
106
+ function submissionFields(options) {
107
+ const { callbackUrl } = options;
108
+ if (callbackUrl != null && typeof callbackUrl !== "string") {
109
+ throw new TypeError(`callbackUrl must be a string, not ${typeof callbackUrl}`);
110
+ }
111
+ const fields = {};
112
+ if (options.locale != null)
113
+ fields.locale = options.locale;
114
+ if (options.aspectRatio != null)
115
+ fields.aspectRatio = options.aspectRatio;
116
+ const selection = normalizePages(options.pages);
117
+ if (selection !== undefined)
118
+ fields.pages = selection;
119
+ if (callbackUrl != null && !isBlank(callbackUrl))
120
+ fields.callbackUrl = callbackUrl;
121
+ return fields;
122
+ }
123
+ /** Whether a header value says `true`, ignoring case and surrounding space. */
124
+ function headerIsTrue(value) {
125
+ return (value ?? "").trim().toLowerCase() === "true";
126
+ }
39
127
  /** How many images are read and re-encoded at the same time. See `#prepareFiles`. */
40
128
  const PREPARE_CONCURRENCY = 4;
41
129
  /**
@@ -128,7 +216,7 @@ class IdleWatchdog {
128
216
  this.#timer = undefined;
129
217
  }
130
218
  }
131
- function buildMultipart(files, options, onChunk) {
219
+ function buildMultipart(files, formFields, onChunk) {
132
220
  const boundary = `----image2ppt-${randomUUID()}`;
133
221
  const chunk = (value) => Buffer.from(value, "utf8");
134
222
  const fileHeader = (file) => `--${boundary}\r\n` +
@@ -136,11 +224,7 @@ function buildMultipart(files, options, onChunk) {
136
224
  `Content-Type: ${file.mime}\r\n\r\n`;
137
225
  const field = (name, value) => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
138
226
  const trailer = `--${boundary}--\r\n`;
139
- const fields = [];
140
- if (options.locale)
141
- fields.push(field("locale", options.locale));
142
- if (options.aspectRatio)
143
- fields.push(field("aspectRatio", options.aspectRatio));
227
+ const fields = Object.entries(formFields).map(([name, value]) => field(name, value));
144
228
  // The body streams, but its length is known before the first byte goes out: image
145
229
  // payloads are already in memory, a PDF's size was measured while preparing it, and
146
230
  // every delimiter is a fixed string. Sending `Content-Length` rather than letting
@@ -423,35 +507,104 @@ export class Image2PPTClient {
423
507
  * Submit a batch of files and create a conversion job.
424
508
  *
425
509
  * Checked locally before anything is uploaded: the files must add up to at most
426
- * 45MB and at most 50 pages. Over either limit this throws without opening a
510
+ * 90MB and at most 50 pages (the pages *selected*, when `pages` is given), and
511
+ * `pages` must be spelled correctly. Over a limit this throws without opening a
427
512
  * connection — going over the size cap on the wire does not come back as a clean
428
513
  * error, it comes back as a dead connection.
429
514
  *
430
- * **A failed submission is never retried automatically.** A network error does
431
- * not tell you whether the request body made it: the job may not exist, or it
432
- * may exist with credits already reserved and only the response lost. Retrying
433
- * the second case charges twice, and without an idempotency key there is no way
434
- * to tell them apart — so the error is thrown as-is. Check `account()` or your
435
- * job list before resending.
515
+ * **Every submission carries an `Idempotency-Key`**, and that is what makes a
516
+ * failed one safe to resend: if an earlier attempt did create the job, the service
517
+ * hands that job back (`job.replayed` is true) instead of creating and charging for
518
+ * a second one. So this call resends by itself, always with the same key, when the
519
+ * outcome is unknown — a dropped connection, an idle timeout, or a 5xx (up to 2
520
+ * more attempts, 1s and 2s apart) — and waits out `IDEMPOTENCY_KEY_IN_PROGRESS`, an
521
+ * earlier attempt the service is still working on (up to 10 attempts / 3 minutes).
522
+ * A 429 is not retried here; `submitAll` waits those out.
523
+ *
524
+ * If it still fails, the key is on the error as `err.idempotencyKey`. Calling
525
+ * `submit` again with that key and the same files and options is safe for 24
526
+ * hours; calling it without the key may create a second job.
436
527
  *
437
528
  * @param paths Local file paths (one or more). png/jpeg/webp/gif/pdf, each ≤ 35MB,
438
- * and ≤ 45MB of file content per request. An image is 1 page, a PDF is its page
529
+ * and ≤ 90MB of file content per request. An image is 1 page, a PDF is its page
439
530
  * count; the total must be ≤ 50 pages. For more files than one request can hold,
440
531
  * use `submitAll` / `convertAll`.
441
- * @returns A `Job` with status `pending`, plus `slideCount` and `creditsReserved`.
532
+ * @returns A `Job` with status `pending` (for a replay: the job's current status),
533
+ * plus `slideCount` and `creditsReserved`.
442
534
  * @throws InvalidFileError Including the local `PAYLOAD_TOO_LARGE` pre-flight failure.
535
+ * @throws InvalidPagesError / PagesOutOfRangeError / TooManySlidesError A selection
536
+ * that is certain to be refused, before anything is sent.
537
+ * @throws IdempotencyKeyMismatchError The key was used for a different request.
538
+ * @throws APIConnectionError / ServerError / IdempotencyKeyInProgressError Once the
539
+ * retries above run out.
443
540
  */
444
541
  async submit(paths, options = {}) {
445
- if (!paths.length) {
542
+ const list = stringList(paths, "paths");
543
+ if (!list.length) {
446
544
  throw new Error("at least one file is required");
447
545
  }
448
- return this.#submitPrepared(await this.#prepareFiles(paths), options);
546
+ const key = resolveIdempotencyKey(options.idempotencyKey);
547
+ const fields = submissionFields(options);
548
+ // The spelling needs no files: refuse a typo before compressing anything. Range
549
+ // and count wait for the page total, so the error is the one the service would
550
+ // give.
551
+ if (fields.pages !== undefined)
552
+ parsePages(fields.pages);
553
+ const files = await this.#prepareFiles(list);
554
+ checkPrepared(files, fields.pages);
555
+ return this.#submitWithKey(() => this.#postFiles(files, fields, key), key);
556
+ }
557
+ /**
558
+ * Create a conversion job from files the service downloads itself.
559
+ *
560
+ * Instead of uploading, hand over `https` links; the service fetches them in order
561
+ * and converts them as one deck. The downloaded files are held to the same limits
562
+ * as uploads (35MB each, 90MB together, 50 pages). File types are judged by
563
+ * content, not by the link or its `Content-Type`.
564
+ *
565
+ * Downloading takes time — up to 60 seconds a link and 120 for the request — so
566
+ * this call waits at least 180 seconds for an answer, whatever the client's
567
+ * `timeoutMs`. Retries and `idempotencyKey` work exactly as in `submit`. Only one
568
+ * submission by URL per account can be in flight at a time; another gets a
569
+ * `RateLimitedError`.
570
+ *
571
+ * @param urls 1–50 `https` links. Each must resolve to a public address.
572
+ * @param options As `submit`. With links the page total is not known here, so
573
+ * `pages` is only checked for spelling and for selecting more than 50 pages.
574
+ * @throws InvalidUrlError A link was refused before downloading.
575
+ * @throws UrlFetchFailedError A download failed; may be temporary. For these two —
576
+ * and `InvalidFileError` about a downloaded file — `err.index` says which link,
577
+ * counting from 0.
578
+ * @throws InvalidParameterError More than 50 links, thrown locally.
579
+ */
580
+ async submitUrls(urls, options = {}) {
581
+ const list = stringList(urls, "urls");
582
+ if (!list.length) {
583
+ throw new Error("at least one URL is required");
584
+ }
585
+ if (list.length > MAX_URLS) {
586
+ throw new InvalidParameterError(`${list.length} URLs in one submission, over the ${MAX_URLS} allowed`, { code: "INVALID_PARAMETER" });
587
+ }
588
+ const key = resolveIdempotencyKey(options.idempotencyKey);
589
+ const fields = submissionFields(options);
590
+ checkPageSelection(fields.pages);
591
+ const body = Buffer.from(JSON.stringify({ urls: list, ...fields }), "utf8");
592
+ return this.#submitWithKey(() => this.#request("POST", "/api/v1/jobs", {
593
+ body: () => ({
594
+ body,
595
+ contentType: "application/json",
596
+ contentLength: body.byteLength,
597
+ }),
598
+ headers: { "Idempotency-Key": key },
599
+ timeoutMs: Math.max(this.timeoutMs, URL_SUBMIT_MIN_TIMEOUT_MS),
600
+ consume: (res) => this.#parseSubmission(res),
601
+ }), key);
449
602
  }
450
603
  /**
451
604
  * Split files into submittable batches and create **one job per batch**.
452
605
  *
453
606
  * For a pile of files too big or too numerous for a single request. Batching
454
- * rules live in `planBatches`: at most 40MB of file content and at most 50
607
+ * rules live in `planBatches`: at most 80MB of file content and at most 50
455
608
  * images per batch, and every PDF in a batch of its own (the SDK does not parse
456
609
  * PDFs, so only the server knows their page count). Input order is preserved.
457
610
  *
@@ -466,8 +619,11 @@ export class Image2PPTClient {
466
619
  * the same batch again. Waiting is the normal path here. Total waiting is capped
467
620
  * by the client's `rateLimitMaxWaitMs`.
468
621
  *
469
- * **Network errors are not retried** — see `submit`. Only a 429 is, because only
470
- * a 429 proves the server did not take the submission.
622
+ * **Each batch is its own submission with its own `Idempotency-Key`**, and is
623
+ * retried exactly as `submit` retries — plus the 429s above, with the same key.
624
+ * There is no `pages` or `idempotencyKey` option here: page numbers run across one
625
+ * submission and a key names one job, so neither can span several batches. Submit
626
+ * batches yourself with `submit` if you need them.
471
627
  *
472
628
  * **If it does give up, the jobs already created are handed back on the error**,
473
629
  * in `err.submittedJobs`. Those jobs are running on the server with credits
@@ -480,10 +636,12 @@ export class Image2PPTClient {
480
636
  * @throws RateLimitedError Still rate limited after `rateLimitMaxWaitMs`.
481
637
  */
482
638
  async submitAll(paths, options = {}) {
483
- if (!paths.length) {
639
+ const list = stringList(paths, "paths");
640
+ if (!list.length) {
484
641
  throw new Error("at least one file is required");
485
642
  }
486
- const files = await this.#prepareFiles(paths);
643
+ const fields = batchFields(options);
644
+ const files = await this.#prepareFiles(list);
487
645
  const batches = planBatches(files.map((file) => ({
488
646
  path: file.path,
489
647
  size: file.size,
@@ -494,7 +652,7 @@ export class Image2PPTClient {
494
652
  const jobs = [];
495
653
  for (const batch of batches) {
496
654
  try {
497
- jobs.push(await this.#submitBatch(batch.map((item) => item.file), options, budget));
655
+ jobs.push(await this.#submitBatch(batch.map((item) => item.file), fields, budget));
498
656
  }
499
657
  catch (err) {
500
658
  // Whatever went wrong, the earlier batches are already jobs on the server
@@ -663,15 +821,32 @@ export class Image2PPTClient {
663
821
  /**
664
822
  * One-shot: submit → wait for completion → download to `destPath`.
665
823
  *
666
- * One job, one PPTX — the files must fit in a single submission (45MB of file
824
+ * One job, one PPTX — the files must fit in a single submission (90MB of file
667
825
  * content, 50 pages). For more than that, `convertAll` splits the pile and writes
668
826
  * one PPTX per batch.
669
827
  */
670
828
  async convert(paths, destPath, options = {}) {
671
- const job = await this.submit(paths, options);
672
- const completed = await this.wait(job.jobId, options);
673
- await this.download(completed.jobId, destPath);
674
- return completed;
829
+ // Resolved here, not in submit: a wait or download that fails after the job
830
+ // exists must hand back the key too, or retrying convert() would create and pay
831
+ // for a second job.
832
+ const idempotencyKey = resolveIdempotencyKey(options.idempotencyKey);
833
+ const job = await this.submit(paths, { ...options, idempotencyKey });
834
+ try {
835
+ const completed = await this.wait(job.jobId, options);
836
+ await this.download(completed.jobId, destPath);
837
+ return completed;
838
+ }
839
+ catch (err) {
840
+ if (typeof err === "object" && err !== null) {
841
+ try {
842
+ err.idempotencyKey = idempotencyKey;
843
+ }
844
+ catch {
845
+ // a frozen error object: keep it as it is rather than replace it
846
+ }
847
+ }
848
+ throw err;
849
+ }
675
850
  }
676
851
  /**
677
852
  * Batch version of `convert`: submit everything, wait, download each deck.
@@ -698,6 +873,9 @@ export class Image2PPTClient {
698
873
  * @returns The written file paths, in batch order.
699
874
  */
700
875
  async convertAll(paths, destDir, options = {}) {
876
+ // Refused before the destination is even created: a plain-JS caller passing
877
+ // `pages` or `idempotencyKey` gets the answer before anything touches the disk.
878
+ batchFields(options);
701
879
  // Before anything is submitted: if the destination is unusable, fail now
702
880
  // rather than after N jobs exist with credits reserved and nowhere to put
703
881
  // their output. This is the one step that can fail for free.
@@ -720,6 +898,55 @@ export class Image2PPTClient {
720
898
  }
721
899
  return written;
722
900
  }
901
+ /**
902
+ * One page of the jobs this account submitted through the API, newest first.
903
+ *
904
+ * Jobs deleted on the website are not listed. Each job has `getJob`'s shape minus
905
+ * `pageResults`; for reconciliation, add up `creditsUsed` and `creditsRefunded`.
906
+ * To walk every page, use `iterJobs`.
907
+ *
908
+ * @throws InvalidParameterError A parameter is invalid; `message` says which.
909
+ */
910
+ async listJobs(options = {}) {
911
+ const params = new URLSearchParams();
912
+ for (const [name, value] of [
913
+ ["createdFrom", options.createdFrom],
914
+ ["createdTo", options.createdTo],
915
+ ["limit", options.limit],
916
+ ["cursor", options.cursor],
917
+ ]) {
918
+ // null/undefined and "" all mean "not given"; everything else goes as given,
919
+ // so the service is the one to judge it — `limit: 0` included.
920
+ if (value != null && value !== "")
921
+ params.set(name, String(value));
922
+ }
923
+ const query = params.toString();
924
+ return new JobList(await this.#request("GET", `/api/v1/jobs${query ? `?${query}` : ""}`, {
925
+ consume: (res) => this.#parseJson(res),
926
+ }));
927
+ }
928
+ /**
929
+ * Every job `listJobs` would list, following `nextCursor` to the end.
930
+ *
931
+ * `limit` is the page size, not a cap on how many jobs come back. A 429 is waited
932
+ * out (`Retry-After`, else 5s), and a page that fails in a way worth repeating
933
+ * (`isTransient`) is fetched again after a short backoff — at most 10 attempts per
934
+ * page before the error is thrown.
935
+ *
936
+ * ```ts
937
+ * for await (const job of client.iterJobs({ createdFrom: "2026-10-01" })) { ... }
938
+ * ```
939
+ */
940
+ async *iterJobs(options = {}) {
941
+ let cursor;
942
+ for (;;) {
943
+ const page = await this.#listPageWithRetries({ ...options, cursor });
944
+ yield* page.data;
945
+ if (page.nextCursor === null)
946
+ return;
947
+ cursor = page.nextCursor;
948
+ }
949
+ }
723
950
  /** Return account info: `{ email, credits }` (available credits). */
724
951
  async account() {
725
952
  return (await this.#request("GET", "/api/v1/account", {
@@ -728,17 +955,21 @@ export class Image2PPTClient {
728
955
  }
729
956
  // ----- internals --------------------------------------------------- //
730
957
  /**
731
- * Submit one batch, waiting out rate limits until `deadline`.
958
+ * Submit one batch, waiting out rate limits while `budget` allows.
732
959
  *
733
- * Retrying a 429 is not the same gamble as retrying a broken upload: a 429 is
734
- * the server saying it did *not* take the submission. Nothing was created and
735
- * nothing was charged, so trying the same batch again is free.
960
+ * The batch was prepared (images compressed) once, in `submitAll`, and gets its
961
+ * `Idempotency-Key` once, here, before the loop — so every attempt sends the very
962
+ * same request under the same key, which is what lets the service recognise a
963
+ * resend.
736
964
  *
737
- * Both flavors of 429 (per-minute page quota, concurrent-job cap) carry a
738
- * `Retry-After` and are handled identically. When the header is missing we fall
739
- * back to a fixed wait.
965
+ * A 429 is the server saying it did *not* take the submission — nothing was
966
+ * created and nothing charged — so trying the same batch again is free. Both
967
+ * flavors (per-minute page quota, concurrent-job cap) carry a `Retry-After` and are
968
+ * handled identically; when the header is missing we fall back to a fixed wait.
969
+ * Other failures are retried inside `#submitWithKey`.
740
970
  */
741
- async #submitBatch(files, options, budget) {
971
+ async #submitBatch(files, fields, budget) {
972
+ const key = resolveIdempotencyKey(undefined);
742
973
  // Two things stop this: the shared waiting `budget`, and MAX_BATCH_ATTEMPTS. The
743
974
  // budget bounds time spent waiting; the attempt count bounds the uploads, which
744
975
  // the budget cannot see — a server answering `Retry-After: 1` forever costs almost
@@ -746,17 +977,108 @@ export class Image2PPTClient {
746
977
  let attemptsLeft = MAX_BATCH_ATTEMPTS;
747
978
  for (;;) {
748
979
  try {
749
- return await this.#submitPrepared(files, options);
980
+ return await this.#submitWithKey(() => this.#postFiles(files, fields, key), key);
750
981
  }
751
982
  catch (err) {
752
983
  if (!(err instanceof RateLimitedError))
753
984
  throw err;
754
985
  attemptsLeft -= 1;
755
- const delay = err.retryAfter != null ? err.retryAfter * 1000 : RATE_LIMIT_FALLBACK_WAIT_MS;
756
986
  // On the last attempt, do not wait first: nothing follows the wait, so it
757
987
  // would only delay the error the caller is already getting.
758
- if (attemptsLeft <= 0 || !(await budget.spend(delay)))
988
+ if (attemptsLeft <= 0 || !(await budget.spend(rateLimitDelayMs(err))))
989
+ throw err;
990
+ }
991
+ }
992
+ }
993
+ /**
994
+ * Send one submission until its outcome is known; see `submit` for the rules.
995
+ *
996
+ * `send` makes one attempt and must send the identical request every time, under
997
+ * `key`. Whatever finally escapes carries `key` as `idempotencyKey`, so the caller
998
+ * can resend safely.
999
+ *
1000
+ * A 429 is left to the caller on purpose. By the contract, resending the same
1001
+ * request under a key whose job exists is answered with that job (and one still
1002
+ * being processed with "in progress"), so a 429 here still means nothing was taken.
1003
+ */
1004
+ async #submitWithKey(send, key) {
1005
+ let retriesUsed = 0;
1006
+ let inProgressAttempts = 0;
1007
+ const inProgressBudget = new WaitBudget(IN_PROGRESS_MAX_WAIT_MS);
1008
+ try {
1009
+ for (let attempt = 1;; attempt += 1) {
1010
+ try {
1011
+ return await send();
1012
+ }
1013
+ catch (err) {
1014
+ if (err instanceof IdempotencyKeyInProgressError) {
1015
+ inProgressAttempts += 1;
1016
+ const firstMs = (err.retryAfter ?? 2) * 1000;
1017
+ const delay = Math.min(firstMs * 1.5 ** (inProgressAttempts - 1), IN_PROGRESS_WAIT_CAP_MS);
1018
+ if (inProgressAttempts >= IN_PROGRESS_MAX_ATTEMPTS ||
1019
+ !(await inProgressBudget.spend(delay))) {
1020
+ throw err;
1021
+ }
1022
+ continue;
1023
+ }
1024
+ if (err instanceof APIConnectionError || err instanceof ServerError) {
1025
+ const delay = SUBMIT_RETRY_DELAYS_MS[retriesUsed];
1026
+ if (delay === undefined)
1027
+ throw err;
1028
+ retriesUsed += 1;
1029
+ await sleep(delay);
1030
+ continue;
1031
+ }
1032
+ if (err instanceof IdempotencyKeyMismatchError && attempt > 1) {
1033
+ // Only a request that created a job holds its key, so an earlier attempt
1034
+ // of this very call did — and the files changed on disk in between, or
1035
+ // this would have been a replay.
1036
+ err.message +=
1037
+ "; an earlier attempt of this call did create a job with this key before " +
1038
+ "the request changed (did a file change on disk?) — find it with listJobs()";
1039
+ }
759
1040
  throw err;
1041
+ }
1042
+ }
1043
+ }
1044
+ catch (err) {
1045
+ // Not only SDK errors: a PDF that vanished between attempts fails with a
1046
+ // filesystem error, and the first attempt may already have created the job.
1047
+ if (typeof err === "object" && err !== null) {
1048
+ try {
1049
+ err.idempotencyKey = key;
1050
+ }
1051
+ catch {
1052
+ // a frozen error object: keep it as it is rather than replace it
1053
+ }
1054
+ }
1055
+ throw err;
1056
+ }
1057
+ }
1058
+ /**
1059
+ * One `listJobs` page for `iterJobs`, retried while that is worth it.
1060
+ *
1061
+ * Listing is a read, so repeating it is free: a 429 waits `Retry-After` and anything
1062
+ * `isTransient` backs off, both within `MAX_BATCH_ATTEMPTS`.
1063
+ */
1064
+ async #listPageWithRetries(options) {
1065
+ let backoff = 1_000;
1066
+ for (let attempt = 1;; attempt += 1) {
1067
+ try {
1068
+ return await this.listJobs(options);
1069
+ }
1070
+ catch (err) {
1071
+ if (attempt >= MAX_BATCH_ATTEMPTS)
1072
+ throw err;
1073
+ if (!(err instanceof Image2PPTError) || !err.isTransient)
1074
+ throw err;
1075
+ if (err instanceof RateLimitedError) {
1076
+ await sleep(rateLimitDelayMs(err));
1077
+ }
1078
+ else {
1079
+ await sleep(backoff);
1080
+ backoff = Math.min(backoff * 2, 15_000);
1081
+ }
760
1082
  }
761
1083
  }
762
1084
  }
@@ -826,18 +1148,26 @@ export class Image2PPTClient {
826
1148
  isImage: true,
827
1149
  };
828
1150
  }
829
- /** Validate and submit a prepared payload exactly once. */
830
- async #submitPrepared(files, options) {
831
- // Pre-flight happens after image preparation because these are the exact bytes
832
- // that will be transmitted, not the potentially much larger source files.
833
- for (const file of files)
834
- checkFileSize(file.path, file.size);
835
- checkSubmission(files.reduce((total, file) => total + file.size, 0), files.filter((file) => file.isImage).length, files.filter((file) => !file.isImage).length);
836
- return Job.fromJson(await this.#request("POST", "/api/v1/jobs", {
1151
+ /**
1152
+ * POST the multipart submission once, under `key`.
1153
+ *
1154
+ * PDFs are reopened and streamed from disk on every attempt; images go from the
1155
+ * bytes compressed once in `#prepareFile`, so a resend carries the same image bytes
1156
+ * as the first attempt.
1157
+ */
1158
+ async #postFiles(files, fields, key) {
1159
+ return this.#request("POST", "/api/v1/jobs", {
837
1160
  // Rebuilt per attempt: a retry needs a body that has not been consumed.
838
- body: (watchdog) => buildMultipart(files, options, () => watchdog.kick()),
839
- consume: (res) => this.#parseJson(res),
840
- }));
1161
+ body: (watchdog) => buildMultipart(files, fields, () => watchdog.kick()),
1162
+ headers: { "Idempotency-Key": key },
1163
+ consume: (res) => this.#parseSubmission(res),
1164
+ });
1165
+ }
1166
+ /** A submission's answer: the job, marked when the service replayed it. */
1167
+ async #parseSubmission(res) {
1168
+ const job = Job.fromJson(await this.#parseJson(res));
1169
+ job.replayed = headerIsTrue(res.headers.get("Idempotent-Replayed"));
1170
+ return job;
841
1171
  }
842
1172
  /**
843
1173
  * Make one HTTP request and consume its response, under an idle watchdog.
@@ -864,7 +1194,8 @@ export class Image2PPTClient {
864
1194
  * `isTransient`. See `readingBody`.
865
1195
  */
866
1196
  async #request(method, path, options) {
867
- const watchdog = new IdleWatchdog(this.timeoutMs, `request to ${path} went ${this.timeoutMs}ms with no data moving ` +
1197
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs;
1198
+ const watchdog = new IdleWatchdog(timeoutMs, `request to ${path} went ${timeoutMs}ms with no data moving ` +
868
1199
  "(timeoutMs is an idle timeout, not a limit on how long a transfer may take)");
869
1200
  try {
870
1201
  const payload = options.body?.(watchdog);
@@ -876,6 +1207,7 @@ export class Image2PPTClient {
876
1207
  Authorization: `Bearer ${this.#apiKey}`,
877
1208
  "User-Agent": USER_AGENT,
878
1209
  ...(this.acceptLanguage ? { "Accept-Language": this.acceptLanguage } : {}),
1210
+ ...options.headers,
879
1211
  ...(payload
880
1212
  ? {
881
1213
  "Content-Type": payload.contentType,
@@ -1019,11 +1351,18 @@ export class Image2PPTClient {
1019
1351
  async #raiseForError(res) {
1020
1352
  let code;
1021
1353
  let message;
1354
+ let index;
1022
1355
  try {
1023
1356
  const body = (await res.json());
1024
1357
  if (body && typeof body === "object" && body.error) {
1025
1358
  code = body.error.code;
1026
1359
  message = body.error.message;
1360
+ // A whole, non-negative number only: `3.0` is 3, `"1"`, `true`, `-1` and
1361
+ // `1.5` are no index at all — the same reading as the Python client.
1362
+ const raw = body.error.index;
1363
+ const whole = wholeNumber(raw);
1364
+ if (whole !== null && whole >= 0)
1365
+ index = whole;
1027
1366
  }
1028
1367
  }
1029
1368
  catch (err) {
@@ -1040,6 +1379,7 @@ export class Image2PPTClient {
1040
1379
  code,
1041
1380
  message: message ?? `request failed (HTTP ${res.status})`,
1042
1381
  retryAfter: parseRetryAfter(res.headers.get("Retry-After")),
1382
+ index,
1043
1383
  });
1044
1384
  }
1045
1385
  /**
@@ -1057,6 +1397,36 @@ export class Image2PPTClient {
1057
1397
  await sleep(Math.min(ms, remaining, MAX_SLEEP_MS));
1058
1398
  }
1059
1399
  }
1400
+ /**
1401
+ * Pre-flight, before a single byte goes out: an oversized request is not answered
1402
+ * with an error, it is cut off — so it must never be sent.
1403
+ */
1404
+ function checkPrepared(files, pages) {
1405
+ // After image preparation, because these are the exact bytes that will be
1406
+ // transmitted, not the potentially much larger source files.
1407
+ for (const file of files)
1408
+ checkFileSize(file.path, file.size);
1409
+ checkSubmission(files.reduce((total, file) => total + file.size, 0), files.filter((file) => file.isImage).length,
1410
+ // A PDF's real page count is only known server-side; counting it as at least 1
1411
+ // is what stops "50 images + a PDF" from being sent as a submission that is
1412
+ // certain to come back over the page limit.
1413
+ files.filter((file) => !file.isImage).length, pages);
1414
+ }
1415
+ /**
1416
+ * The fields for `submitAll` / `convertAll`, refusing the two options that cannot
1417
+ * span several batches. The types already leave them out; this is for plain
1418
+ * JavaScript, where a silently ignored `pages` would convert pages nobody asked for.
1419
+ */
1420
+ function batchFields(options) {
1421
+ const extra = options;
1422
+ for (const name of ["pages", "idempotencyKey"]) {
1423
+ if (extra[name] !== undefined) {
1424
+ throw new TypeError(`${name} is not supported by submitAll / convertAll: it cannot span several ` +
1425
+ "batches. Use submit() for one submission");
1426
+ }
1427
+ }
1428
+ return submissionFields(options);
1429
+ }
1060
1430
  /** Pull the URL out of a `Link: <url>; rel=...` header, or null. */
1061
1431
  function linkUrl(value) {
1062
1432
  if (!value)