craterview 0.4.16 → 0.4.18

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.
Files changed (3) hide show
  1. package/README.md +17 -9
  2. package/index.ts +303 -57
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -86,6 +86,10 @@ const job = await cv.run(image, { // Blob | ArrayBuffer | Uint8Array
86
86
  It waits server-side first, so a job that finishes quickly costs a single round trip, then
87
87
  falls back to polling for anything slower. A job that fails throws `JobFailed`.
88
88
 
89
+ Past `timeoutMs` only the waiting stops. The job carries on and is charged only if it
90
+ succeeds; the `CraterViewError` thrown carries its `.jobId`, and `cv.waitFor(error.jobId)`
91
+ collects it. Calling `run()` again would start, and bill, a second job.
92
+
89
93
  Which parameters a model accepts is published by the API rather than hard-coded here — see
90
94
  `cv.models()`.
91
95
 
@@ -105,9 +109,12 @@ a presigned URL and `blob()` GETs the result the same way; the API moves keys, n
105
109
 
106
110
  ## Retries are yours to write
107
111
 
108
- **This client does not retry.** That is deliberate: only the caller knows where one logical
109
- request ends and the next begins, and a retry the client invented would be a second job you
110
- are billed for.
112
+ **Outside `run()` and `waitFor()`, this client does not retry.** `run()` asks its submission
113
+ once more, under one idempotency key, when the first got no answer or a failure on the
114
+ platform's side, and `waitFor()` reads on through the same until its timeout; neither can make a
115
+ second job. Every other call is sent once. That is deliberate: only the caller knows where one
116
+ logical request ends and the next begins, and a retry the client invented would be a second
117
+ job you are billed for.
111
118
 
112
119
  What it gives you instead is a key generator. Make one key per *logical request* and reuse
113
120
  it for every attempt at that request:
@@ -139,15 +146,16 @@ the same job.
139
146
  Every answer the API or storage gives that is not a success is thrown as a
140
147
  `CraterViewError`. `.status` carries the HTTP status where there was one, and `.traceId` the
141
148
  API's `X-Trace-Id` for a request it answered and refused — the id to quote when you ask us
142
- about it. A request that got
149
+ about it. `.jobId` names the job an error is about, where there is one. A request that got
143
150
  no answer at all throws the runtime's own error from `fetch`, which is how the retry loop
144
151
  above tells the two apart.
145
152
 
146
153
  | Class | Meaning |
147
154
  |---|---|
148
155
  | `RateLimited` | 429 — one of five limits, and the message says which: the key's request rate, the account's upload URLs a minute, the account's new keys an hour, the account's jobs in flight, or a full queue. `.retryAfter` is seconds to wait: until the window rolls over for the two per-minute limits, until a new key can be made for the hourly one, or an interval to poll on for the other two. |
149
- | `JobFailed` | The job ran and did not succeed. `.message` says what you can do about it; `.errorCode` is the half to branch on. |
150
- | `CraterViewError` | Everything else, including 4xx and 5xx from the API. |
156
+ | `JobFailed` | The job ran and did not succeed. `.message` says what you can do about it; `.errorCode` is the half to branch on; `.jobId` is the job. |
157
+ | `InvalidSignature` | A webhook delivery did not verify. Do not act on its body. |
158
+ | `CraterViewError` | Everything else, including 4xx and 5xx from the API, and storage refusing an upload or a download. |
151
159
 
152
160
  These subclass `Error` under an ES2020 target so `instanceof` works. Compiling this package
153
161
  down to ES5 silently breaks that, and `catch (e) { if (e instanceof RateLimited) }` — the
@@ -170,7 +178,6 @@ Every field the API publishes on a job is exposed here.
170
178
  | `outputUrl`, `downloadUrl` | The result, presigned. One to display, one to save |
171
179
  | `thumbnailUrl` | A small JPEG of the job's picture, for listings — the result, or what the model worked from when it produced no file. Null when none was drawn |
172
180
  | `inputUrl` | The picture the model worked from — the region, where you named one |
173
- | `alphaUrl` | Only for a JPEG result of a picture with transparency, which JPEG cannot hold: the transparency as a grayscale JPEG, white where opaque. The result is then the colour alone |
174
181
  | `contentType` | The result's media type |
175
182
  | `outputBytes` | The result's size in bytes |
176
183
  | `blob()`, `arrayBuffer()` | Download the result |
@@ -309,8 +316,9 @@ new CraterView({
309
316
 
310
317
  `baseUrl` is what you change to point at a local server. `timeoutMs` is how long to wait for
311
318
  the API to answer before giving up with the runtime's `TimeoutError` — nothing answered, so
312
- it is a request to repeat with the same idempotency key. Uploads and downloads go to storage
313
- and have five minutes.
319
+ it is a request to repeat with the same idempotency key. `run()` uploads afresh each time, so
320
+ from it look for the job in `cv.jobs()` instead of running it again. Uploads and downloads go
321
+ to storage and have five minutes.
314
322
 
315
323
  ## From Claude Code
316
324
 
package/index.ts CHANGED
@@ -18,7 +18,7 @@ import { imageSize } from "image-size";
18
18
  // Mirrored from package.json, which is the number a release bumps. It cannot be imported
19
19
  // from there — this ships as TypeScript, so the import would have to resolve in the
20
20
  // consumer's toolchain — so test/version.test.ts asserts the two agree.
21
- export const VERSION = "0.4.16";
21
+ export const VERSION = "0.4.18";
22
22
  const DEFAULT_BASE_URL = "https://api.craterview.ai";
23
23
  // The server rejects a longer wait outright, so asking for one costs a 422 rather than the
24
24
  // wait you asked for. `run()` clamps to this rather than letting that happen.
@@ -26,6 +26,12 @@ const MAX_SERVER_WAIT = 30;
26
26
  // The largest page the job history serves. Same reasoning: a bigger `limit` is refused, so
27
27
  // `jobs()` clamps rather than spending a request to be told no.
28
28
  const MAX_PAGE = 200;
29
+ // The shape of each kind of id the API names in a path, exactly as it issues them. A method
30
+ // that puts an id in its path takes one of these and nothing else, because anything else builds
31
+ // some other request than the one the method names: `..` is a dot segment that fetch resolves
32
+ // before sending, so `revokeKey("..")` would be `DELETE /v1/`, and encoding does not help,
33
+ // since `%2e%2e` is resolved the same way.
34
+ const IDS = { job: /^job_[0-9a-f]{24}$/, key: /^key_[0-9a-f]{24}$/ } as const;
29
35
 
30
36
  /**
31
37
  * A random key for safely repeating a submission.
@@ -51,7 +57,8 @@ const MAX_PAGE = 200;
51
57
  * Reusing a key with a *different* request body is rejected with 409 rather than silently
52
58
  * returning the earlier result.
53
59
  *
54
- * This client does not retry on your behalf. The loop above is yours to write.
60
+ * `submit()` does not retry on your behalf: the loop above is yours to write. `run()`, which is
61
+ * one whole request from upload to result, keeps a key of its own and asks once more.
55
62
  */
56
63
  export function newIdempotencyKey(): string {
57
64
  const bytes = new Uint8Array(24);
@@ -62,13 +69,79 @@ export function newIdempotencyKey(): string {
62
69
  return `idem_${base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
63
70
  }
64
71
 
72
+ /**
73
+ * The media type a file's bytes are, from the signature at their start, or `""` for one this
74
+ * does not recognise. Formats the API does not take are named too, so that a refusal says
75
+ * what the file is rather than that it is unknown.
76
+ */
77
+ export async function sniffType(blob: Blob): Promise<string> {
78
+ const b = new Uint8Array(await blob.slice(0, 64).arrayBuffer());
79
+ const at = (offset: number, text: string) =>
80
+ [...text].every((c, i) => b[offset + i] === c.charCodeAt(0));
81
+ const starts = (...bytes: number[]) => bytes.every((x, i) => b[i] === x);
82
+ if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
83
+ if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
84
+ if (at(0, "RIFF") && at(8, "WEBP")) return "image/webp";
85
+ if (starts(0x49, 0x49, 0x2a, 0x00) || starts(0x4d, 0x4d, 0x00, 0x2a)
86
+ || starts(0x49, 0x49, 0x2b, 0x00) || starts(0x4d, 0x4d, 0x00, 0x2b)) return "image/tiff";
87
+ if (starts(0xff, 0x0a) || (starts(0, 0, 0, 0x0c) && at(4, "JXL "))) return "image/jxl";
88
+ if (at(0, "GIF87a") || at(0, "GIF89a")) return "image/gif";
89
+ if (at(4, "ftyp")) {
90
+ // An ISO base media file names its brands: the major one, then the compatible ones to
91
+ // the end of the `ftyp` box. An AVIF may declare the generic `mif1` first and `avif`
92
+ // among the rest, so every brand is read before deciding.
93
+ const word = (o: number) => String.fromCharCode(...b.slice(o, o + 4));
94
+ const end = Math.min(new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(0), b.length);
95
+ const major = word(8);
96
+ const brands = [major];
97
+ for (let o = 16; o + 4 <= end; o += 4) brands.push(word(o));
98
+ if (brands.includes("avif") || brands.includes("avis")) return "image/avif";
99
+ if (["heic", "heix", "heim", "heis"].includes(major)) return "image/heic";
100
+ if (["mif1", "mif2", "msf1"].includes(major)) return "image/heif";
101
+ }
102
+ return "";
103
+ }
104
+
105
+ /** `value`, if it is a `kind` id as the API issues them; otherwise a {@link CraterViewError}. */
106
+ function issued(kind: keyof typeof IDS, value: unknown, argument: string): string {
107
+ if (typeof value === "string" && IDS[kind].test(value)) return value;
108
+ throw new CraterViewError(`${argument} must be a ${kind} id as the API returned it — ${kind}_ `
109
+ + `and 24 hexadecimal digits — not ${JSON.stringify(value)}; nothing was sent`);
110
+ }
111
+
112
+ /** How long `run()` waits before asking a submission again: the pause the web page, the phone
113
+ * app and the assistant connector take. */
114
+ const RESUBMIT_AFTER_MS = 2_000;
115
+
116
+ const pause = (ms: number) => new Promise((r) => setTimeout(r, ms));
117
+
118
+ /** No answer, or a failure on the platform's side or on the way to it: asked again, the request
119
+ * may be answered. `fetch` rejects with a `TypeError` only when nothing came back. A refusal, a
120
+ * 4xx, is the same answer the next time. */
121
+ function worthAskingAgain(e: unknown): boolean {
122
+ return (e as Error)?.name === "TimeoutError" || e instanceof TypeError
123
+ || (e instanceof CraterViewError && (e.status ?? 0) >= 500);
124
+ }
125
+
126
+ /** The API's own 503 to a submission: the one status it answers before anything is recorded,
127
+ * so nothing was queued or charged. A 503 page from something on the way carries no trace id
128
+ * and says nothing about the job. */
129
+ function queuedNothing(e: unknown): boolean {
130
+ return e instanceof CraterViewError && e.status === 503 && e.traceId !== undefined;
131
+ }
132
+
65
133
  /**
66
134
  * `status` is the HTTP status where there was one. `traceId` is the API's `X-Trace-Id` for a
67
135
  * request it answered and refused — the id to quote when asking about it. Absent when the
68
136
  * refusal came from storage rather than the API.
137
+ *
138
+ * `jobId` is the job an error is about, where there is one: a job that failed, one whose wait
139
+ * ran out while it carried on, or one whose result could not be fetched. Quote it when asking
140
+ * about the job, and collect a job still going by it.
69
141
  */
70
142
  export class CraterViewError extends Error {
71
- constructor(message: string, readonly status?: number, readonly traceId?: string) {
143
+ constructor(message: string, readonly status?: number, readonly traceId?: string,
144
+ readonly jobId?: string) {
72
145
  super(message);
73
146
  this.name = "CraterViewError";
74
147
  }
@@ -108,11 +181,12 @@ export class RateLimited extends CraterViewError {
108
181
  * ours, nothing was charged, and the same call is worth making again.
109
182
  *
110
183
  * An unfamiliar code is a failure with no special handling, not an error in itself: new ones
111
- * appear as new things become worth telling apart.
184
+ * appear as new things become worth telling apart. `jobId` is the job, to quote when asking
185
+ * about it.
112
186
  */
113
187
  export class JobFailed extends CraterViewError {
114
- constructor(message: string, readonly errorCode?: string) {
115
- super(message);
188
+ constructor(message: string, readonly errorCode?: string, jobId?: string) {
189
+ super(message, undefined, undefined, jobId);
116
190
  this.name = "JobFailed";
117
191
  }
118
192
  }
@@ -134,7 +208,12 @@ export const DEFAULT_TOLERANCE_SECONDS = 300;
134
208
  * header, a timestamp too old — because from a receiver's point of view they are one
135
209
  * outcome: this is not something we sent, so it does not get to do anything.
136
210
  */
137
- export class InvalidSignature extends CraterViewError {}
211
+ export class InvalidSignature extends CraterViewError {
212
+ constructor(message: string) {
213
+ super(message);
214
+ this.name = "InvalidSignature";
215
+ }
216
+ }
138
217
 
139
218
  /**
140
219
  * Check that a callback really came from CraterView, and return its parsed body.
@@ -162,6 +241,12 @@ export async function verifyWebhook(
162
241
  options: { toleranceSeconds?: number } = {},
163
242
  ): Promise<WebhookEvent> {
164
243
  const { toleranceSeconds = DEFAULT_TOLERANCE_SECONDS } = options;
244
+ // Before the delivery is looked at: an unset secret is this receiver's configuration, not a
245
+ // delivery that failed to check out, and refusing every delivery as forged would hide it.
246
+ if (!secret) {
247
+ throw new CraterViewError("verifyWebhook was given no secret: pass the one "
248
+ + "webhookSecret() returns, or the one on the dashboard");
249
+ }
165
250
 
166
251
  const signature = readHeader(headers, SIGNATURE_HEADER);
167
252
  const timestamp = readHeader(headers, TIMESTAMP_HEADER);
@@ -169,21 +254,30 @@ export async function verifyWebhook(
169
254
  throw new InvalidSignature("delivery is missing its signature headers");
170
255
  }
171
256
 
172
- const sentAt = Number(timestamp);
173
- if (!Number.isFinite(sentAt)) {
257
+ // Whole seconds in ASCII digits, as the platform writes it, and nothing else: `Number()`
258
+ // would also take whitespace of any script, a sign, an exponent or hex. Fifteen digits is
259
+ // past any time a clock will show.
260
+ if (!/^[0-9]{1,15}$/.test(timestamp)) {
174
261
  throw new InvalidSignature("delivery has an unreadable timestamp");
175
262
  }
263
+ const sentAt = Number(timestamp);
176
264
  // Both directions. A clock ahead of ours is as much a reason to distrust a delivery as one
177
265
  // behind it, and only checking the past accepts anything dated next year.
178
266
  const age = Date.now() / 1000 - sentAt;
179
- if (toleranceSeconds && Math.abs(age) > toleranceSeconds) {
180
- throw new InvalidSignature(`delivery is ${age.toFixed(0)}s out of date`);
267
+ if (toleranceSeconds && age > toleranceSeconds) {
268
+ throw new InvalidSignature(`delivery was signed ${age.toFixed(0)}s ago, longer ago than the `
269
+ + `${toleranceSeconds}s allowed: a copy replayed late, or a clock here that is wrong`);
270
+ }
271
+ if (toleranceSeconds && -age > toleranceSeconds) {
272
+ throw new InvalidSignature(`delivery is dated ${(-age).toFixed(0)}s ahead of this machine's `
273
+ + `clock, more than the ${toleranceSeconds}s allowed: check the clock here`);
181
274
  }
182
275
 
183
276
  const bytes = toBytes(body);
184
- const signed = new Uint8Array(timestamp.length + 1 + bytes.length);
185
- signed.set(new TextEncoder().encode(`${timestamp}.`), 0);
186
- signed.set(bytes, timestamp.length + 1);
277
+ const prefix = new TextEncoder().encode(`${timestamp}.`);
278
+ const signed = new Uint8Array(prefix.length + bytes.length);
279
+ signed.set(prefix, 0);
280
+ signed.set(bytes, prefix.length);
187
281
 
188
282
  const key = await crypto.subtle.importKey(
189
283
  "raw", new TextEncoder().encode(secret),
@@ -318,6 +412,11 @@ export interface ModelInfo {
318
412
  credits_per_video_second?: number | null;
319
413
  reference_fps: number;
320
414
  params_schema: Record<string, unknown>;
415
+ /**
416
+ * Every name in `params_schema`'s `properties`, in the order a form presents them. JSON does
417
+ * not order an object's keys, so a form drawn from the schema reads its order from here.
418
+ */
419
+ params_order: string[];
321
420
  /**
322
421
  * JSON Schema for the fields a finished job of this model states under `result`, beside
323
422
  * any files it links. Empty for a model whose whole answer is its file.
@@ -431,8 +530,9 @@ export interface JobData {
431
530
  community?: boolean;
432
531
  /**
433
532
  * Whether an automated check thought this job's images may fall outside what the
434
- * service allows: the one you sent, and for some jobs the one that came back. Absent
435
- * means they were not all checked.
533
+ * service allows: the part of your image the job worked on (all of it, unless you
534
+ * named a region), and for some jobs the one that came back. Absent means they were
535
+ * not all checked.
436
536
  */
437
537
  flagged?: boolean | null;
438
538
  }
@@ -456,8 +556,6 @@ export interface JobData {
456
556
  * worked from when it produced no file — for showing a page of jobs without downloading a
457
557
  * page of full-size pictures. `inputUrl` is the picture the model worked from, so a result
458
558
  * can be shown against what it was made from. Both expire with the job's other pictures.
459
- * `alphaUrl` is there only when you asked for a JPEG of a picture with transparency: the
460
- * transparency, as a file of its own.
461
559
  *
462
560
  * `etaSeconds` is the whole of what is reported about waiting: how long until the result,
463
561
  * counting time spent waiting for a GPU as well as time spent on one. An estimate and never
@@ -533,18 +631,6 @@ export class Job {
533
631
  ? ((given as Record<string, unknown>)["url"] as string) ?? null
534
632
  : null;
535
633
  }
536
- /**
537
- * The transparency of a result you asked for as JPEG, which cannot hold it: a grayscale
538
- * JPEG the size of the result, white where it is opaque and black where it is transparent.
539
- * The result is then the colour alone, not placed on any background, so the two together
540
- * are the picture. Null for every other result.
541
- */
542
- get alphaUrl() {
543
- const mask = (this.data.result ?? {})["alpha"];
544
- return (mask && typeof mask === "object")
545
- ? ((mask as Record<string, unknown>)["url"] as string) ?? null
546
- : null;
547
- }
548
634
  get contentType() { return (this.output["content_type"] as string) ?? null; }
549
635
  get outputBytes() { return (this.output["bytes"] as number) ?? null; }
550
636
 
@@ -566,16 +652,40 @@ export class Job {
566
652
  /** Download the result. */
567
653
  async blob(): Promise<Blob> {
568
654
  const url = this.outputUrl;
569
- if (!url) {
570
- throw new CraterViewError(`job ${this.id} has no result (status ${this.status})`);
571
- }
655
+ if (!url) throw new CraterViewError(this.noFile(), undefined, undefined, this.id);
572
656
  const resp = await fetch(url, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) });
657
+ // A link is as old as the read that brought it, and storage answers one past its expiry
658
+ // with 403 — so that is what a 403 almost always is, and a fresh read the fix.
659
+ if (resp.status === 403) {
660
+ throw new CraterViewError(`storage refused the result of job ${this.id} (HTTP 403): its `
661
+ + `link has most likely expired. Read the job again, cv.job("${this.id}"), for fresh `
662
+ + `links.`, 403, undefined, this.id);
663
+ }
573
664
  if (!resp.ok) {
574
- throw new CraterViewError(`downloading result failed: ${resp.status}`, resp.status);
665
+ throw new CraterViewError(`storage refused the result of job ${this.id}: HTTP `
666
+ + `${resp.status}`, resp.status, undefined, this.id);
575
667
  }
576
668
  return await resp.blob();
577
669
  }
578
670
 
671
+ /** Why there is no file to download, which depends on where the job stands. */
672
+ private noFile(): string {
673
+ if (!this.done) {
674
+ return `job ${this.id} has no result yet: it is still ${this.status}. Wait for it with `
675
+ + `waitFor() first.`;
676
+ }
677
+ if (this.status === "failed") {
678
+ const code = this.errorCode ? ` (${this.errorCode})` : "";
679
+ return `job ${this.id} failed, so it has no result: `
680
+ + `${this.error ?? "no reason was given"}${code}`;
681
+ }
682
+ // Succeeded, with no file. Either its model answers in `result` alone, or the file has
683
+ // gone; a job read after its files expire looks the same as one that never had any, so
684
+ // both are said rather than one guessed at.
685
+ return `job ${this.id} has no file to download: its model answers in \`result\` alone, `
686
+ + "or its file has expired or been deleted";
687
+ }
688
+
579
689
  async arrayBuffer(): Promise<ArrayBuffer> {
580
690
  return await (await this.blob()).arrayBuffer();
581
691
  }
@@ -587,8 +697,10 @@ export interface CraterViewOptions {
587
697
  /**
588
698
  * Milliseconds to wait for the API to answer a request before giving up, 60 000 by default.
589
699
  * A request that times out throws the runtime's `TimeoutError`, not a `CraterViewError`:
590
- * nothing answered, so it is one to repeat with the same idempotency key. Uploads and
591
- * downloads go to storage and are given five minutes whatever this says.
700
+ * nothing answered, so it is one to repeat with the same idempotency key — except from
701
+ * `run()`, which uploads afresh each time, where the message says to look in `jobs()` for
702
+ * the job instead. Uploads and downloads go to storage and are given five minutes whatever
703
+ * this says.
592
704
  */
593
705
  timeoutMs?: number;
594
706
  }
@@ -640,6 +752,26 @@ export interface SubmitOptions {
640
752
  [param: string]: unknown;
641
753
  }
642
754
 
755
+ /**
756
+ * The API's `detail` as one sentence, or `undefined` where it holds no words.
757
+ *
758
+ * Usually a string. A request its validator refuses comes back as a list instead, one entry
759
+ * per field, which reads here as "field: what is wrong" — and without the validator's own
760
+ * "Value error, " in front of a sentence the API wrote for a reader.
761
+ */
762
+ function spoken(detail: unknown): string | undefined {
763
+ if (typeof detail === "string") return detail || undefined;
764
+ if (!Array.isArray(detail)) return undefined;
765
+ const said = detail
766
+ .filter((e): e is { loc?: unknown[]; msg: string } => typeof e?.msg === "string" && !!e.msg)
767
+ .map(({ loc, msg }) => {
768
+ const message = msg.replace(/^Value error, /, "");
769
+ // A location of one part — the body as a whole — names no field worth printing.
770
+ return Array.isArray(loc) && loc.length > 1 ? `${loc[loc.length - 1]}: ${message}` : message;
771
+ });
772
+ return said.join("; ") || undefined;
773
+ }
774
+
643
775
  export class CraterView {
644
776
  private readonly baseUrl: string;
645
777
  private readonly headers: Record<string, string>;
@@ -666,9 +798,9 @@ export class CraterView {
666
798
  body: body ? JSON.stringify(body) : undefined,
667
799
  });
668
800
 
801
+ const traceId = resp.headers.get("X-Trace-Id") ?? undefined;
669
802
  if (!resp.ok) {
670
803
  const detail = await this.detail(resp);
671
- const traceId = resp.headers.get("X-Trace-Id") ?? undefined;
672
804
  if (resp.status === 429) {
673
805
  throw new RateLimited(detail, Number(resp.headers.get("Retry-After") ?? 0), traceId);
674
806
  }
@@ -676,16 +808,38 @@ export class CraterView {
676
808
  }
677
809
  // A 204 carries no body, so asking for JSON throws on a call that succeeded.
678
810
  if (resp.status === 204) return undefined as T;
679
- return await resp.json() as T;
811
+ try {
812
+ return await resp.json() as T;
813
+ } catch {
814
+ throw new CraterViewError(this.notTheApi(resp.status), resp.status, traceId);
815
+ }
680
816
  }
681
817
 
818
+ /** The API's own words for a refusal, or a sentence saying the answer was not its. */
682
819
  private async detail(resp: Response): Promise<string> {
820
+ let body: unknown;
683
821
  try {
684
- const body = await resp.json() as { detail?: string };
685
- return body.detail ?? `HTTP ${resp.status}`;
822
+ body = await resp.json();
686
823
  } catch {
687
- return `HTTP ${resp.status}`;
824
+ return this.notTheApi(resp.status);
825
+ }
826
+ return spoken((body as { detail?: unknown } | null)?.detail) ?? this.notTheApi(resp.status);
827
+ }
828
+
829
+ /**
830
+ * For an answer that is not the API's JSON: an error page from something between here and
831
+ * the API, or a `baseUrl` that names something else. Its body is left out — a page of markup
832
+ * buries the one line worth reading.
833
+ */
834
+ private notTheApi(status: number): string {
835
+ const said = `${new URL(this.baseUrl).host} answered HTTP ${status} with something other `
836
+ + "than the API's JSON";
837
+ if (status >= 500) {
838
+ return `${said}: a failure on the way to the API rather than a refusal. Worth repeating — `
839
+ + "with the same idempotency key, for a submission, so it cannot start a second job.";
688
840
  }
841
+ if (status === 429) return `${said}.`;
842
+ return `${said}; check that baseUrl is the API's address (${DEFAULT_BASE_URL} by default).`;
689
843
  }
690
844
 
691
845
  /**
@@ -713,12 +867,21 @@ export class CraterView {
713
867
  * key, so `submit()` can tell the API what you sent and `eta_seconds` is estimated for
714
868
  * your image rather than a typical one. A file the reader does not know is uploaded just
715
869
  * the same; only the estimate is less specific.
870
+ *
871
+ * The type sent is `contentType` when given, else the one the bytes' signature shows, else
872
+ * the Blob's own, else none — an undeclared upload is accepted and identified when the job
873
+ * runs. The bytes come before the Blob's type because that is usually read off the file's
874
+ * name, and a misnamed file is then refused here rather than as a job. A type the API does not take is refused by the first request, before any
875
+ * bytes are sent, with the list of what it does take.
716
876
  */
717
877
  async upload(image: Blob | ArrayBuffer | Uint8Array, contentType?: string): Promise<string> {
718
- const blob = image instanceof Blob
719
- ? image
720
- : new Blob([image as BlobPart], { type: contentType ?? "image/png" });
721
- const type = contentType ?? blob.type ?? "image/png";
878
+ const blob = image instanceof Blob ? image : new Blob([image as BlobPart]);
879
+ // Sent, it would come back as a refusal of `content_length`, a field the caller never
880
+ // wrote. Said here in their terms instead, before anything is requested.
881
+ if (!blob.size) {
882
+ throw new CraterViewError("the image given is empty (0 bytes): there is nothing to upload");
883
+ }
884
+ const type = contentType || await sniffType(blob) || blob.type || "application/octet-stream";
722
885
 
723
886
  // Declared, then signed into the URL, so it has to be the size actually sent — storage
724
887
  // refuses any other with a signature error. Taken from the Blob rather than trusted from
@@ -740,7 +903,8 @@ export class CraterView {
740
903
  }
741
904
 
742
905
  /**
743
- * Queue a job. `wait` holds the response open for a settled result.
906
+ * Queue a job. `wait` holds the response open for a settled result, for up to that many
907
+ * seconds; the API holds one for at most 30, so a longer wait is asked as 30.
744
908
  *
745
909
  * Pass `idempotencyKey` — see `newIdempotencyKey` — if you intend to retry this
746
910
  * submission. Without one, a repeat after a failed or uncertain request starts a second
@@ -755,7 +919,11 @@ export class CraterView {
755
919
  const headers: Record<string, string> = idempotencyKey
756
920
  ? { "Idempotency-Key": idempotencyKey }
757
921
  : {};
758
- const data = await this.request<JobData>("POST", `/v1/jobs?wait=${wait}`, {
922
+ // Held to the API's ceiling, so a longer wait is a long poll rather than a refusal. None at
923
+ // all is the API's own default, and is left unsaid.
924
+ const held = Math.min(wait, MAX_SERVER_WAIT);
925
+ const path = held > 0 ? `/v1/jobs?wait=${held}` : "/v1/jobs";
926
+ const data = await this.request<JobData>("POST", path, {
759
927
  model, input_key: inputKey, params,
760
928
  ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
761
929
  ...(customId ? { custom_id: customId } : {}),
@@ -764,8 +932,12 @@ export class CraterView {
764
932
  return new Job(data);
765
933
  }
766
934
 
935
+ /**
936
+ * One job, by the `id` the API gave it. Anything that is not a job id throws a
937
+ * `CraterViewError` without a request being made.
938
+ */
767
939
  async job(id: string): Promise<Job> {
768
- return new Job(await this.request<JobData>("GET", `/v1/jobs/${id}`));
940
+ return new Job(await this.request<JobData>("GET", `/v1/jobs/${issued("job", id, "id")}`));
769
941
  }
770
942
 
771
943
  /**
@@ -879,33 +1051,107 @@ export class CraterView {
879
1051
  * You cannot revoke the key this client is authenticating with: it is refused with status
880
1052
  * 409, because it would leave you unable to make another. Create a replacement, move onto
881
1053
  * it, then revoke this one.
1054
+ *
1055
+ * `keyId` is a key's `id` from `keys()` or `createKey()`, never its value or its prefix.
1056
+ * Anything else throws a `CraterViewError` without a request being made.
882
1057
  */
883
1058
  async revokeKey(keyId: string): Promise<void> {
884
- await this.request("DELETE", `/v1/keys/${encodeURIComponent(keyId)}`);
1059
+ await this.request("DELETE", `/v1/keys/${issued("key", keyId, "keyId")}`);
885
1060
  }
886
1061
 
1062
+ /**
1063
+ * Wait until a job settles. Prefer `wait` on submit for short jobs.
1064
+ *
1065
+ * Past `timeoutMs` this throws a `CraterViewError` with the job's `jobId`. Only the wait has
1066
+ * ended: the job carries on, and is collected by calling this again with that id.
1067
+ *
1068
+ * A read that gets no answer, a failure on the way to the API or a request to slow down is
1069
+ * not the job's, and the wait goes on through it until `timeoutMs`. A refusal it throws
1070
+ * carries the `jobId` too, so no failure here loses the job.
1071
+ */
887
1072
  async waitFor(job: Job | string, timeoutMs = 600_000, pollMs = 1000): Promise<Job> {
888
- const id = typeof job === "string" ? job : job.id;
1073
+ const id = issued("job", typeof job === "string" ? job : job.id, "job");
1074
+ let current = typeof job === "string" ? undefined : job;
889
1075
  const deadline = Date.now() + timeoutMs;
890
1076
  while (Date.now() < deadline) {
891
- const current = await this.job(id);
1077
+ try {
1078
+ current = await this.job(id);
1079
+ } catch (e) {
1080
+ if (e instanceof RateLimited || worthAskingAgain(e)) {
1081
+ await pause(Math.max(pollMs, ((e as RateLimited).retryAfter ?? 0) * 1000));
1082
+ continue;
1083
+ }
1084
+ if (e instanceof CraterViewError && !e.jobId) {
1085
+ throw new CraterViewError(e.message, e.status, e.traceId, id);
1086
+ }
1087
+ throw e;
1088
+ }
892
1089
  if (current.done) return current;
893
- await new Promise((r) => setTimeout(r, pollMs));
1090
+ await pause(pollMs);
894
1091
  }
895
- throw new CraterViewError(`job ${id} did not finish within ${timeoutMs}ms`);
1092
+ const where = current ? `still ${current.status}` : "not finished";
1093
+ throw new CraterViewError(`job ${id} is ${where} after ${timeoutMs / 1000}s of waiting. It `
1094
+ + "has not failed: it carries on, and is charged only if it succeeds. Collect it with "
1095
+ + `waitFor("${id}") or job("${id}"); submitting the request again starts a second job, `
1096
+ + "charged as well.", undefined, undefined, id);
896
1097
  }
897
1098
 
898
1099
  /**
899
1100
  * Upload, submit and wait, in one call — the common case. Uses server-side waiting
900
- * first so a fast job costs a single round trip, then polls for anything slower.
1101
+ * first so a fast job costs a single round trip, then polls for anything slower. The
1102
+ * server is asked to hold the submission for at most half this client's own `timeoutMs`,
1103
+ * so its answer arrives before the client stops listening for it.
1104
+ *
1105
+ * Past `timeoutMs` the job is not given up on, only waited for: the error carries its
1106
+ * `jobId`, to collect it by with `waitFor`.
1107
+ *
1108
+ * **One submission, asked at most twice.** A submission that got no answer, or met a failure
1109
+ * on the platform's side, may have made the job before it failed, so it is asked once more
1110
+ * under one idempotency key, and the API answers with the job the first attempt made if it
1111
+ * made one — never a second. Pass `idempotencyKey` to choose the key. Refused both times by
1112
+ * the API's own 503, which it answers before anything is recorded, the error says so in its
1113
+ * words.
901
1114
  */
902
1115
  async run(image: Blob | ArrayBuffer | Uint8Array,
903
1116
  options: SubmitOptions & { timeoutMs?: number } = {}): Promise<Job> {
904
- const { timeoutMs = 600_000, wait = MAX_SERVER_WAIT, ...rest } = options;
1117
+ const { timeoutMs = 600_000, wait = MAX_SERVER_WAIT, idempotencyKey = newIdempotencyKey(),
1118
+ ...rest } = options;
905
1119
  const inputKey = await this.upload(image);
906
- let job = await this.submit(inputKey, { ...rest, wait: Math.min(wait, MAX_SERVER_WAIT) });
1120
+ const submitted = () => this.submit(inputKey, {
1121
+ ...rest, idempotencyKey, wait: Math.min(wait, this.timeoutMs / 2000) });
1122
+ let job: Job;
1123
+ let first: unknown;
1124
+ try {
1125
+ try {
1126
+ job = await submitted();
1127
+ } catch (e) {
1128
+ if (!worthAskingAgain(e)) throw e;
1129
+ first = e;
1130
+ await pause(RESUBMIT_AFTER_MS);
1131
+ job = await submitted();
1132
+ }
1133
+ } catch (e) {
1134
+ if (!worthAskingAgain(e)) throw e;
1135
+ if (queuedNothing(first) && queuedNothing(e)) {
1136
+ const refused = e as CraterViewError;
1137
+ throw new CraterViewError(
1138
+ `the submission was asked twice, and both times CraterView said why: ${refused.message}`,
1139
+ refused.status, refused.traceId);
1140
+ }
1141
+ // Thrown as the kind it was, so a caller telling no answer from a refusal by type sees
1142
+ // what it saw before. Only the advice changes: run() uploads afresh each time.
1143
+ const said = `the submission was asked twice and answered neither time `
1144
+ + `(${(e as Error).message}), so a job may have been made; if it was, it runs and is `
1145
+ + "charged only if it succeeds. Look for it with jobs(), newest first, before running "
1146
+ + "this again: run() uploads afresh, so repeating it starts a second job.";
1147
+ if (e instanceof CraterViewError) throw new CraterViewError(said, e.status, e.traceId);
1148
+ if ((e as Error).name === "TimeoutError") throw new DOMException(said, "TimeoutError");
1149
+ throw new TypeError(said);
1150
+ }
907
1151
  if (!job.done) job = await this.waitFor(job, timeoutMs);
908
- if (!job.succeeded) throw new JobFailed(job.error ?? "job failed", job.errorCode ?? undefined);
1152
+ if (!job.succeeded) {
1153
+ throw new JobFailed(job.error ?? "job failed", job.errorCode ?? undefined, job.id);
1154
+ }
909
1155
  return job;
910
1156
  }
911
1157
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craterview",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
4
4
  "dependencies": {
5
5
  "image-size": "^2.0.4"
6
6
  },