partial-content 1.0.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +601 -0
  4. package/SECURITY.md +92 -0
  5. package/dist/azure.d.ts +79 -0
  6. package/dist/azure.d.ts.map +1 -0
  7. package/dist/azure.js +251 -0
  8. package/dist/azure.js.map +1 -0
  9. package/dist/content-disposition.d.ts +74 -0
  10. package/dist/content-disposition.d.ts.map +1 -0
  11. package/dist/content-disposition.js +253 -0
  12. package/dist/content-disposition.js.map +1 -0
  13. package/dist/fs.d.ts +86 -0
  14. package/dist/fs.d.ts.map +1 -0
  15. package/dist/fs.js +375 -0
  16. package/dist/fs.js.map +1 -0
  17. package/dist/gcs.d.ts +72 -0
  18. package/dist/gcs.d.ts.map +1 -0
  19. package/dist/gcs.js +202 -0
  20. package/dist/gcs.js.map +1 -0
  21. package/dist/hono.d.ts +92 -0
  22. package/dist/hono.d.ts.map +1 -0
  23. package/dist/hono.js +61 -0
  24. package/dist/hono.js.map +1 -0
  25. package/dist/http.d.ts +70 -0
  26. package/dist/http.d.ts.map +1 -0
  27. package/dist/http.js +281 -0
  28. package/dist/http.js.map +1 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +27 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/kernel.d.ts +541 -0
  34. package/dist/kernel.d.ts.map +1 -0
  35. package/dist/kernel.js +1218 -0
  36. package/dist/kernel.js.map +1 -0
  37. package/dist/memory.d.ts +55 -0
  38. package/dist/memory.d.ts.map +1 -0
  39. package/dist/memory.js +107 -0
  40. package/dist/memory.js.map +1 -0
  41. package/dist/mime.d.ts +49 -0
  42. package/dist/mime.d.ts.map +1 -0
  43. package/dist/mime.js +150 -0
  44. package/dist/mime.js.map +1 -0
  45. package/dist/node.d.ts +84 -0
  46. package/dist/node.d.ts.map +1 -0
  47. package/dist/node.js +215 -0
  48. package/dist/node.js.map +1 -0
  49. package/dist/object-store.d.ts +472 -0
  50. package/dist/object-store.d.ts.map +1 -0
  51. package/dist/object-store.js +335 -0
  52. package/dist/object-store.js.map +1 -0
  53. package/dist/r2.d.ts +94 -0
  54. package/dist/r2.d.ts.map +1 -0
  55. package/dist/r2.js +150 -0
  56. package/dist/r2.js.map +1 -0
  57. package/dist/s3.d.ts +49 -0
  58. package/dist/s3.d.ts.map +1 -0
  59. package/dist/s3.js +263 -0
  60. package/dist/s3.js.map +1 -0
  61. package/dist/web.d.ts +336 -0
  62. package/dist/web.d.ts.map +1 -0
  63. package/dist/web.js +1094 -0
  64. package/dist/web.js.map +1 -0
  65. package/docs/DESIGN.md +426 -0
  66. package/package.json +182 -0
  67. package/src/azure.ts +329 -0
  68. package/src/content-disposition.ts +300 -0
  69. package/src/fs.ts +469 -0
  70. package/src/gcs.ts +290 -0
  71. package/src/hono.ts +123 -0
  72. package/src/http.ts +351 -0
  73. package/src/index.ts +85 -0
  74. package/src/kernel.ts +1498 -0
  75. package/src/memory.ts +148 -0
  76. package/src/mime.ts +160 -0
  77. package/src/node.ts +261 -0
  78. package/src/object-store.ts +665 -0
  79. package/src/r2.ts +232 -0
  80. package/src/s3.ts +324 -0
  81. package/src/web.ts +1603 -0
package/package.json ADDED
@@ -0,0 +1,182 @@
1
+ {
2
+ "name": "partial-content",
3
+ "version": "1.0.0",
4
+ "description": "RFC-compliant HTTP file serving for any storage backend. Range requests (206), conditional caching (304/412), Content-Disposition, and ETag generation. Zero dependencies.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./s3": {
15
+ "types": "./dist/s3.d.ts",
16
+ "import": "./dist/s3.js",
17
+ "default": "./dist/s3.js"
18
+ },
19
+ "./web": {
20
+ "types": "./dist/web.d.ts",
21
+ "import": "./dist/web.js",
22
+ "default": "./dist/web.js"
23
+ },
24
+ "./fs": {
25
+ "types": "./dist/fs.d.ts",
26
+ "import": "./dist/fs.js",
27
+ "default": "./dist/fs.js"
28
+ },
29
+ "./node": {
30
+ "types": "./dist/node.d.ts",
31
+ "import": "./dist/node.js",
32
+ "default": "./dist/node.js"
33
+ },
34
+ "./hono": {
35
+ "types": "./dist/hono.d.ts",
36
+ "import": "./dist/hono.js",
37
+ "default": "./dist/hono.js"
38
+ },
39
+ "./gcs": {
40
+ "types": "./dist/gcs.d.ts",
41
+ "import": "./dist/gcs.js",
42
+ "default": "./dist/gcs.js"
43
+ },
44
+ "./azure": {
45
+ "types": "./dist/azure.d.ts",
46
+ "import": "./dist/azure.js",
47
+ "default": "./dist/azure.js"
48
+ },
49
+ "./r2": {
50
+ "types": "./dist/r2.d.ts",
51
+ "import": "./dist/r2.js",
52
+ "default": "./dist/r2.js"
53
+ },
54
+ "./mime": {
55
+ "types": "./dist/mime.d.ts",
56
+ "import": "./dist/mime.js",
57
+ "default": "./dist/mime.js"
58
+ },
59
+ "./http": {
60
+ "types": "./dist/http.d.ts",
61
+ "import": "./dist/http.js",
62
+ "default": "./dist/http.js"
63
+ },
64
+ "./memory": {
65
+ "types": "./dist/memory.d.ts",
66
+ "import": "./dist/memory.js",
67
+ "default": "./dist/memory.js"
68
+ },
69
+ "./package.json": "./package.json"
70
+ },
71
+ "files": [
72
+ "dist",
73
+ "src",
74
+ "!src/__tests__",
75
+ "README.md",
76
+ "docs/DESIGN.md",
77
+ "CHANGELOG.md",
78
+ "SECURITY.md",
79
+ "LICENSE"
80
+ ],
81
+ "publishConfig": {
82
+ "registry": "https://registry.npmjs.org",
83
+ "access": "public"
84
+ },
85
+ "sideEffects": false,
86
+ "keywords": [
87
+ "http",
88
+ "range-request",
89
+ "partial-content",
90
+ "byte-range",
91
+ "206",
92
+ "304",
93
+ "412",
94
+ "416",
95
+ "conditional-request",
96
+ "content-disposition",
97
+ "etag",
98
+ "etag-generation",
99
+ "s3",
100
+ "r2",
101
+ "gcs",
102
+ "azure-blob",
103
+ "object-storage",
104
+ "supabase-storage",
105
+ "presigned-url",
106
+ "hetzner",
107
+ "streaming",
108
+ "video-seeking",
109
+ "pdf",
110
+ "resume-download",
111
+ "file-serving",
112
+ "optimistic-concurrency",
113
+ "cloudflare-workers",
114
+ "bun",
115
+ "deno",
116
+ "hono",
117
+ "nextjs",
118
+ "express",
119
+ "fastify"
120
+ ],
121
+ "scripts": {
122
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
123
+ "prepare": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
124
+ "test": "bun test --isolate --timeout 30000",
125
+ "test:mutation": "stryker run",
126
+ "test:mutation:ci": "node scripts/mutation-ci.mjs",
127
+ "prepublishOnly": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc && node -e \"const t=require('child_process').execSync('npm pack --dry-run --json',{encoding:'utf8'});const f=JSON.parse(t)[0].files.map(f=>f.path);const bad=f.filter(p=>/\\.(map|env|test|spec)/.test(p)&&!p.endsWith('.d.ts.map')&&!p.endsWith('.js.map'));if(bad.length){console.error('BLOCKED: tarball contains suspicious files:',bad);process.exit(1)}\"",
128
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
129
+ "bench": "npm run build && node bench/run.mjs",
130
+ "smoke": "npm run build && node smoke/smoke.mjs",
131
+ "check:exports": "npm run build && attw --pack . --profile esm-only",
132
+ "check:consumer-types": "npm run build && tsc --noEmit -p smoke/tsconfig.consumer.json",
133
+ "test:live": "bun test src/__tests__/s3-live.test.ts"
134
+ },
135
+ "license": "MIT",
136
+ "author": "Nordvec <oss@nordvec.com>",
137
+ "repository": {
138
+ "type": "git",
139
+ "url": "git+https://github.com/nordvec/partial-content.git"
140
+ },
141
+ "bugs": "https://github.com/nordvec/partial-content/issues",
142
+ "homepage": "https://github.com/nordvec/partial-content#readme",
143
+ "engines": {
144
+ "node": ">=20.0.0"
145
+ },
146
+ "peerDependencies": {
147
+ "@aws-sdk/client-s3": "^3.400.0",
148
+ "@aws-sdk/s3-request-presigner": "^3.400.0",
149
+ "@google-cloud/storage": "^7.0.0",
150
+ "@azure/storage-blob": "^12.0.0",
151
+ "hono": "^4.0.0"
152
+ },
153
+ "peerDependenciesMeta": {
154
+ "@aws-sdk/client-s3": {
155
+ "optional": true
156
+ },
157
+ "@aws-sdk/s3-request-presigner": {
158
+ "optional": true
159
+ },
160
+ "@google-cloud/storage": {
161
+ "optional": true
162
+ },
163
+ "@azure/storage-blob": {
164
+ "optional": true
165
+ },
166
+ "hono": {
167
+ "optional": true
168
+ }
169
+ },
170
+ "devDependencies": {
171
+ "@arethetypeswrong/cli": "^0.18.4",
172
+ "@aws-sdk/client-s3": "^3.1075.0",
173
+ "@aws-sdk/s3-request-presigner": "^3.1075.0",
174
+ "@stryker-mutator/core": "^9.6.1",
175
+ "@types/node": "^26.1.0",
176
+ "autocannon": "^8.0.0",
177
+ "fast-check": "^4.8.0",
178
+ "send": "^1.2.1",
179
+ "sirv": "^3.0.2",
180
+ "typescript": "^6.0.3"
181
+ }
182
+ }
package/src/azure.ts ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Azure Blob Storage ObjectStore adapter for partial-content.
3
+ *
4
+ * Wraps `@azure/storage-blob` to implement the {@link ObjectStore} interface.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { BlobServiceClient } from "@azure/storage-blob";
9
+ * import { azureStore } from "partial-content/azure";
10
+ *
11
+ * const client = BlobServiceClient.fromConnectionString(connectionString);
12
+ * const store = azureStore({
13
+ * containerClient: client.getContainerClient("documents"),
14
+ * });
15
+ * ```
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+
20
+ import {
21
+ ObjectNotFoundError,
22
+ ObjectChangedError,
23
+ StoreUnavailableError,
24
+ classifyStoreRead,
25
+ nodeStreamToWeb,
26
+ guardStreamLength,
27
+ resolveServedRange,
28
+ parseRetryAfterSeconds,
29
+ isOpenEndedRange,
30
+ type ObjectStore,
31
+ type ObjectMetadata,
32
+ type ObjectStream,
33
+ type ParsedRange,
34
+ type StoreErrorClassifiers,
35
+ } from "./index.js";
36
+
37
+ // Re-export for convenience
38
+ export { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError };
39
+
40
+ // ─── Azure Types ────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Minimal Azure ContainerClient interface.
44
+ *
45
+ * Declared locally to avoid a hard dependency on `@azure/storage-blob`.
46
+ * Users who import `partial-content/azure` will have it installed as an
47
+ * optional peer dependency.
48
+ */
49
+ interface AzureContainerClient {
50
+ getBlobClient(blobName: string): AzureBlobClient;
51
+ }
52
+
53
+ interface AzureBlobClient {
54
+ getProperties(options?: { abortSignal?: AbortSignal }): Promise<AzureBlobProperties>;
55
+ download(
56
+ offset?: number,
57
+ count?: number,
58
+ options?: { conditions?: { ifMatch?: string }; abortSignal?: AbortSignal },
59
+ ): Promise<AzureBlobDownloadResponse>;
60
+ }
61
+
62
+ interface AzureBlobProperties {
63
+ contentLength?: number;
64
+ etag?: string;
65
+ lastModified?: Date;
66
+ }
67
+
68
+ interface AzureBlobDownloadResponse {
69
+ readableStreamBody?: NodeJS.ReadableStream & AsyncIterable<Buffer> & { destroy?: () => void };
70
+ blobBody?: Promise<Blob>;
71
+ contentLength?: number;
72
+ /** Present on ranged downloads: "bytes 5-9/20". Carries the total size. */
73
+ contentRange?: string;
74
+ etag?: string;
75
+ lastModified?: Date;
76
+ }
77
+
78
+ // ─── Options ────────────────────────────────────────────────────────────────
79
+
80
+ export interface AzureStoreOptions {
81
+ /** Pre-configured Azure ContainerClient. */
82
+ containerClient: AzureContainerClient;
83
+ }
84
+
85
+ // ─── Factory ────────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Create an {@link ObjectStore} backed by an Azure Blob Storage container.
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * import { BlobServiceClient } from "@azure/storage-blob";
93
+ * import { azureStore } from "partial-content/azure";
94
+ *
95
+ * const blobService = BlobServiceClient.fromConnectionString(conn);
96
+ * const store = azureStore({
97
+ * containerClient: blobService.getContainerClient("documents"),
98
+ * });
99
+ *
100
+ * const meta = await store.headObject("reports/q4.pdf");
101
+ * ```
102
+ */
103
+ export function azureStore(opts: AzureStoreOptions): ObjectStore {
104
+ const { containerClient } = opts;
105
+
106
+ return {
107
+ supportsRange: true,
108
+ // 206 bounds/total come from Azure's actual contentRange: the
109
+ // orchestrator may skip the validating HEAD for plain range requests.
110
+ authoritativeRange: true,
111
+
112
+ async headObject(key: string, opts?: { signal?: AbortSignal }): Promise<ObjectMetadata> {
113
+ opts?.signal?.throwIfAborted();
114
+ // Forward the signal so a client disconnect during the request/header
115
+ // phase cancels the Azure call (the pre-check only covers already-aborted;
116
+ // the body phase is covered by nodeStreamToWeb's signal wiring).
117
+ const props = await classifyStoreRead(key, () => containerClient.getBlobClient(key).getProperties({ abortSignal: opts?.signal }), azureClassifiers);
118
+
119
+ if (props.contentLength == null) {
120
+ throw new Error(`Azure getProperties returned no contentLength for ${key}`);
121
+ }
122
+
123
+ return {
124
+ contentLength: props.contentLength,
125
+ etag: props.etag,
126
+ lastModified: props.lastModified?.toUTCString(),
127
+ };
128
+ },
129
+
130
+ async getObject(key: string, opts?: { range?: ParsedRange; signal?: AbortSignal; ifMatch?: string }): Promise<ObjectStream> {
131
+ const { range, signal, ifMatch } = opts ?? {};
132
+ signal?.throwIfAborted();
133
+
134
+ // Single round-trip: download() carries everything needed. For ranged
135
+ // reads the response's Content-Range ("bytes 5-9/20") supplies the
136
+ // total size; for full reads contentLength IS the total. One response
137
+ // also means one representation -- no metadata/body race to guard.
138
+ const offset = range ? range.start : 0;
139
+ // An open-ended fast-path range (OPEN_ENDED sentinel end) reads to the
140
+ // end of the blob: leave count undefined so download() streams the
141
+ // tail rather than requesting ~9e15 bytes.
142
+ const count = range && !isOpenEndedRange(range)
143
+ ? (range.end - range.start + 1)
144
+ : undefined;
145
+
146
+ const response = await classifyStoreRead(key, () => containerClient.getBlobClient(key).download(
147
+ offset,
148
+ count,
149
+ {
150
+ // Pin the read to the validated representation: Azure rejects
151
+ // with 412 ConditionNotMet if the blob changed since HEAD.
152
+ ...(ifMatch ? { conditions: { ifMatch } } : {}),
153
+ // Forward the signal so the request/header phase is cancellable too.
154
+ abortSignal: signal,
155
+ },
156
+ ), azureClassifiers);
157
+
158
+ if (response.contentLength == null) {
159
+ destroyAzureDownload(response);
160
+ throw new Error(`Azure download returned no contentLength for ${key}`);
161
+ }
162
+ const contentLength = response.contentLength;
163
+ const resolved = response.contentRange
164
+ ? resolveServedRange(response.contentRange)
165
+ : null;
166
+ // A Content-Range Azure emits but the resolver cannot parse means the
167
+ // byte accounting is untrustworthy: release the live socket and fail
168
+ // loudly rather than guess.
169
+ if (response.contentRange && !resolved) {
170
+ destroyAzureDownload(response);
171
+ throw new Error(
172
+ `Azure returned unparseable Content-Range for ${key}: ${response.contentRange}`,
173
+ );
174
+ }
175
+ // For a full read (no Content-Range) contentLength IS the total; a ranged
176
+ // read carries the resolver's honest total (undefined for `bytes a-b/*`).
177
+ const totalSize = resolved ? resolved.totalSize : contentLength;
178
+
179
+ // Azure SDK returns different body types depending on the environment
180
+ let webStream: ReadableStream<Uint8Array<ArrayBuffer>>;
181
+ if (response.readableStreamBody) {
182
+ // Node.js environment: use shared stream conversion
183
+ const nodeStream = response.readableStreamBody;
184
+ // nodeStreamToWeb auto-detects the stream's destroy() capability.
185
+ // expectedBytes guards a graceful short-read: the Azure SDK can end
186
+ // readableStreamBody cleanly when its in-stream retries are exhausted
187
+ // mid-download, which would otherwise under-run the committed
188
+ // Content-Length undetected.
189
+ webStream = nodeStreamToWeb(nodeStream, { signal, expectedBytes: contentLength });
190
+ } else if (response.blobBody) {
191
+ // Browser environment: guard the buffered blob's byte count against the
192
+ // committed length for parity with the Node path's expectedBytes check.
193
+ const blob = await response.blobBody;
194
+ webStream = guardStreamLength(blob.stream() as ReadableStream<Uint8Array>, contentLength);
195
+ } else {
196
+ throw new Error(`Azure download returned no body for ${key}`);
197
+ }
198
+
199
+ return {
200
+ body: webStream,
201
+ contentLength,
202
+ totalSize,
203
+ // The bounds Azure ACTUALLY served, from its response Content-Range.
204
+ range: resolved ? resolved.served : undefined,
205
+ etag: response.etag,
206
+ lastModified: response.lastModified?.toUTCString(),
207
+ };
208
+ },
209
+ };
210
+ }
211
+
212
+ // ─── Internal Helpers ───────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Azure `RestError`'s numeric HTTP status, when present. This is the
216
+ * AUTHORITATIVE classification signal: when a numeric `statusCode` exists, the
217
+ * classifiers key off it exactly and never fall through to message heuristics,
218
+ * so a `503 ServerBusy` whose message text happens to contain "412" (a request
219
+ * id or timestamp digit run) can never be misread as a 412. A present-but-
220
+ * non-numeric status returns `undefined` so the code/message fallback still runs.
221
+ */
222
+ function azureStatusCode(err: unknown): number | undefined {
223
+ if (typeof err === "object" && err !== null && "statusCode" in err) {
224
+ const s = (err as { statusCode: unknown }).statusCode;
225
+ if (typeof s === "number") return s;
226
+ }
227
+ return undefined;
228
+ }
229
+
230
+ /**
231
+ * Azure `RestError`'s stable error code (`BlobNotFound`, `ConditionNotMet`,
232
+ * `ServerBusy`, ...). Matched exactly, so unlike a message substring it cannot
233
+ * collide with arbitrary text in the error message.
234
+ */
235
+ function azureErrorCode(err: unknown): string | undefined {
236
+ if (typeof err === "object" && err !== null && "code" in err) {
237
+ const c = (err as { code: unknown }).code;
238
+ if (typeof c === "string") return c;
239
+ }
240
+ return undefined;
241
+ }
242
+
243
+ function isAzureNotFound(err: unknown): boolean {
244
+ const status = azureStatusCode(err);
245
+ if (status !== undefined) return status === 404;
246
+ if (azureErrorCode(err) === "BlobNotFound") return true;
247
+ return err instanceof Error && err.name === "RestError" && err.message.includes("BlobNotFound");
248
+ }
249
+
250
+ /**
251
+ * Release Azure's live download socket before bailing out on a malformed
252
+ * response. `download()` resolves once headers arrive with an OPEN
253
+ * `readableStreamBody`; throwing before that stream is handed to
254
+ * `nodeStreamToWeb` would leak the socket (and its connection-pool slot)
255
+ * until GC. The browser `blobBody` path buffers in memory and needs no
256
+ * teardown.
257
+ */
258
+ function destroyAzureDownload(response: AzureBlobDownloadResponse): void {
259
+ response.readableStreamBody?.destroy?.();
260
+ }
261
+
262
+ /**
263
+ * Check if an Azure error is a failed ifMatch condition (blob changed).
264
+ * The SDK surfaces this as a RestError with statusCode 412 (ConditionNotMet).
265
+ * A numeric status is authoritative; the `code`/message fallback only runs for
266
+ * status-less shapes, so a throttle carrying "412" in its message text is never
267
+ * misreported as a precondition failure.
268
+ */
269
+ function isAzurePreconditionFailed(err: unknown): boolean {
270
+ const status = azureStatusCode(err);
271
+ if (status !== undefined) return status === 412;
272
+ if (azureErrorCode(err) === "ConditionNotMet") return true;
273
+ return err instanceof Error && err.name === "RestError" && err.message.includes("ConditionNotMet");
274
+ }
275
+
276
+ /**
277
+ * The back-off Azure advises on a throttle, parsed from the `Retry-After`
278
+ * response header (whole seconds; the HTTP-date form is ignored). Surfaced so
279
+ * the 503 echoes it and shared caches wait the advised interval.
280
+ */
281
+ function azureRetryAfterSeconds(err: unknown): number | undefined {
282
+ const headers = (err as { response?: { headers?: { get?: (name: string) => string | undefined } } })
283
+ .response?.headers;
284
+ // The classifier runs on arbitrary (S3-compatible/proxy) error shapes, so
285
+ // harden the structural access: optional-call guards only null/undefined, but
286
+ // a truthy non-function `.get` would throw, and a throwing `.get` propagates
287
+ // -- either way the thrown error would replace the throttle and turn a 429/503
288
+ // into a 502. Gate on the type and swallow a throwing getter.
289
+ const get = headers?.get;
290
+ if (typeof get !== "function") return undefined;
291
+ let raw: string | undefined;
292
+ try {
293
+ raw = get.call(headers, "retry-after");
294
+ } catch {
295
+ return undefined;
296
+ }
297
+ return parseRetryAfterSeconds(raw);
298
+ }
299
+
300
+ /**
301
+ * Whether an Azure error is a transient throttle/overload the client should
302
+ * retry (mapped to a 503, not a 502). Azure's retryable transients are
303
+ * `ServerBusy` (HTTP 503) and `OperationTimedOut` (HTTP **500**), so they are
304
+ * matched by ERROR CODE first: keying only off the numeric status would demote
305
+ * `OperationTimedOut` to a non-retryable 502 (500 is neither 429 nor 503). A
306
+ * numeric 429/503 still catches throttles that arrive without a code, and the
307
+ * message fallback (gated on a status-less shape) preserves the classifier's
308
+ * exclusivity -- a 503 whose message merely contains "412" is classified by
309
+ * code/status, never by substring. Returns the advised `Retry-After` when set.
310
+ */
311
+ function isAzureThrottled(err: unknown): boolean | { retryAfterSeconds: number } {
312
+ const code = azureErrorCode(err);
313
+ const status = azureStatusCode(err);
314
+ const throttled =
315
+ code === "ServerBusy" || code === "OperationTimedOut"
316
+ || status === 429 || status === 503
317
+ || (status === undefined && err instanceof Error && err.name === "RestError"
318
+ && (err.message.includes("ServerBusy") || err.message.includes("OperationTimedOut")));
319
+ if (!throttled) return false;
320
+ const retryAfterSeconds = azureRetryAfterSeconds(err);
321
+ return retryAfterSeconds !== undefined ? { retryAfterSeconds } : true;
322
+ }
323
+
324
+ /** The ordered error-classification set shared by getProperties and download. */
325
+ const azureClassifiers: StoreErrorClassifiers = {
326
+ notFound: isAzureNotFound,
327
+ changed: isAzurePreconditionFailed,
328
+ throttled: isAzureThrottled,
329
+ };