@buildinternet/uploads 0.46.1 → 0.46.2

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 (2) hide show
  1. package/dist/client.js +95 -15
  2. package/package.json +2 -2
package/dist/client.js CHANGED
@@ -4,14 +4,104 @@ import { UploadsError } from "./errors.js";
4
4
  import { buildScreenshotKey } from "./keys.js";
5
5
  import { packageVersion } from "./package-version.js";
6
6
  import { resolveEmbedUrl } from "./public-urls.js";
7
- async function jsonRequest(url, init) {
8
- let res;
7
+ // --- Request resilience (issue #809) ---------------------------------------
8
+ //
9
+ // Bare `fetch` has no timeout — undici's default header timeout is ~5
10
+ // minutes, so a backend stall (see the 2026-08-23 D1 stall incident, #808)
11
+ // hangs the CLI silently for that whole window instead of failing fast like
12
+ // the workers now do (#805-#807). Every core API call routes through
13
+ // `resilientFetch` for a bounded timeout and a single bounded retry.
14
+ //
15
+ // Timeouts: short for JSON control calls, longer for the one content-bytes
16
+ // call (file `put`), matching the side-channel calls' pattern (telemetry.ts,
17
+ // update-check.ts already carry AbortController timeouts — this closes the
18
+ // gap on the core path).
19
+ const JSON_TIMEOUT_MS = 15_000;
20
+ const CONTENT_TIMEOUT_MS = 60_000;
21
+ // At most one retry. Retried on network errors, 503, and 429 — and only for
22
+ // GET/PUT, since a `put` is byte-idempotent (same key, same bytes) but a
23
+ // POST/DELETE/PATCH might not be, and telling "no bytes sent" apart from "the
24
+ // mutation already landed" isn't reliable enough to risk a double-apply.
25
+ const MAX_ATTEMPTS = 2;
26
+ const RETRYABLE_METHODS = new Set(["GET", "PUT"]);
27
+ const RETRYABLE_STATUSES = new Set([429, 503]);
28
+ const DEFAULT_RETRY_DELAY_MS = 2_000;
29
+ // Cap an honored X-Retry-After so a large server-suggested backoff can't
30
+ // stall a hook/CI step for minutes.
31
+ const MAX_RETRY_AFTER_DELAY_MS = 10_000;
32
+ function sleep(ms) {
33
+ return new Promise((resolve) => setTimeout(resolve, ms));
34
+ }
35
+ /** Seconds-valued retry delay from the response, capped; falls back to the default backoff. */
36
+ function retryDelayMs(res) {
37
+ const raw = res?.headers.get("x-retry-after") ?? res?.headers.get("retry-after");
38
+ const seconds = raw ? Number(raw) : NaN;
39
+ if (Number.isFinite(seconds) && seconds > 0) {
40
+ return Math.min(seconds * 1000, MAX_RETRY_AFTER_DELAY_MS);
41
+ }
42
+ return DEFAULT_RETRY_DELAY_MS;
43
+ }
44
+ /** One-line stderr notice so a hook/CI log explains the pause (issue #809). */
45
+ function printRetryNotice(label, delayMs) {
46
+ const seconds = delayMs % 1000 === 0 ? `${delayMs / 1000}s` : `${Math.round(delayMs) / 1000}s`;
47
+ process.stderr.write(`warning: uploads.sh ${label}, retrying in ${seconds}…\n`);
48
+ }
49
+ /**
50
+ * fetch with a per-request AbortController timeout. A timeout (or any other
51
+ * fetch rejection) surfaces as `UploadsError` code `NETWORK`, naming the
52
+ * timeout so hook/CI logs are diagnosable.
53
+ */
54
+ async function fetchWithTimeout(url, init, timeoutMs) {
55
+ const controller = new AbortController();
56
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
9
57
  try {
10
- res = await fetch(url, init);
58
+ return await fetch(url, { ...init, signal: controller.signal });
11
59
  }
12
60
  catch (err) {
61
+ if (controller.signal.aborted) {
62
+ throw new UploadsError(`request timed out after ${timeoutMs}ms`, "NETWORK");
63
+ }
13
64
  throw new UploadsError(err instanceof Error ? err.message : "network request failed", "NETWORK");
14
65
  }
66
+ finally {
67
+ clearTimeout(timer);
68
+ }
69
+ }
70
+ /**
71
+ * The shared resilience core for both `jsonRequest` and
72
+ * `createUploadsClient`'s `request`: bounded timeout + bounded retry on
73
+ * network errors, 503, and 429, honoring `X-Retry-After` when present.
74
+ * Never retries a non-idempotent method (see `RETRYABLE_METHODS` above).
75
+ * Returns the raw `Response` on the final attempt — callers still handle
76
+ * `!res.ok` themselves via `parseErrorResponse`.
77
+ */
78
+ async function resilientFetch(method, url, init, timeoutMs) {
79
+ const retryable = RETRYABLE_METHODS.has(method.toUpperCase());
80
+ for (let attempt = 1;; attempt++) {
81
+ let res;
82
+ let networkErr;
83
+ try {
84
+ res = await fetchWithTimeout(url, init, timeoutMs);
85
+ }
86
+ catch (err) {
87
+ networkErr = err;
88
+ }
89
+ const canRetry = retryable &&
90
+ attempt < MAX_ATTEMPTS &&
91
+ (networkErr !== undefined || (res !== undefined && RETRYABLE_STATUSES.has(res.status)));
92
+ if (!canRetry) {
93
+ if (networkErr)
94
+ throw networkErr;
95
+ return res;
96
+ }
97
+ const delayMs = retryDelayMs(res);
98
+ printRetryNotice(res ? `responded ${res.status}` : "request failed", delayMs);
99
+ await sleep(delayMs);
100
+ }
101
+ }
102
+ async function jsonRequest(url, init) {
103
+ const method = (init.method ?? "GET").toUpperCase();
104
+ const res = await resilientFetch(method, url, init, JSON_TIMEOUT_MS);
15
105
  if (!res.ok)
16
106
  throw await parseErrorResponse(res);
17
107
  return (await res.json());
@@ -280,18 +370,7 @@ export function createUploadsClient(config) {
280
370
  if (opts?.auth !== false) {
281
371
  headers.Authorization = `Bearer ${config.token}`;
282
372
  }
283
- let res;
284
- try {
285
- res = await fetch(path, {
286
- method,
287
- headers,
288
- body: opts?.body,
289
- });
290
- }
291
- catch (err) {
292
- const message = err instanceof Error ? err.message : "network request failed";
293
- throw new UploadsError(message, "NETWORK");
294
- }
373
+ const res = await resilientFetch(method, path, { method, headers, body: opts?.body }, opts?.longTimeout ? CONTENT_TIMEOUT_MS : JSON_TIMEOUT_MS);
295
374
  if (!res.ok) {
296
375
  throw await parseErrorResponse(res);
297
376
  }
@@ -383,6 +462,7 @@ export function createUploadsClient(config) {
383
462
  const result = await request("PUT", `${canonicalFilesBase(config)}/${encodeKeyPath(key)}`, {
384
463
  body,
385
464
  headers,
465
+ longTimeout: true,
386
466
  });
387
467
  if (result.url == null) {
388
468
  throw new UploadsError("upload succeeded but workspace has no publicBaseUrl", "NO_PUBLIC_URL", 201);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.46.1",
3
+ "version": "0.46.2",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -48,7 +48,7 @@
48
48
  "devDependencies": {
49
49
  "@types/node": "^26.1.0",
50
50
  "ai": "^6.0.0",
51
- "files-sdk": "^2.2.4",
51
+ "files-sdk": "^2.2.5",
52
52
  "typescript": "^7.0.2",
53
53
  "vitest": "^4.1.10"
54
54
  },