@ttt-productions/media-processing-core 0.8.3 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/inspection/inspect-media.d.ts +57 -0
- package/dist/inspection/inspect-media.d.ts.map +1 -0
- package/dist/inspection/inspect-media.js +412 -0
- package/dist/inspection/inspect-media.js.map +1 -0
- package/dist/run-pipeline.d.ts +29 -1
- package/dist/run-pipeline.d.ts.map +1 -1
- package/dist/run-pipeline.js +49 -12
- package/dist/run-pipeline.js.map +1 -1
- package/dist/server/r2-retry.d.ts +91 -0
- package/dist/server/r2-retry.d.ts.map +1 -0
- package/dist/server/r2-retry.js +250 -0
- package/dist/server/r2-retry.js.map +1 -0
- package/dist/server/storage-ops.d.ts +7 -0
- package/dist/server/storage-ops.d.ts.map +1 -1
- package/dist/server/storage-ops.js +75 -29
- package/dist/server/storage-ops.js.map +1 -1
- package/dist/video/ffmpeg.d.ts +14 -0
- package/dist/video/ffmpeg.d.ts.map +1 -1
- package/dist/video/ffmpeg.js +44 -2
- package/dist/video/ffmpeg.js.map +1 -1
- package/package.json +2 -2
package/dist/run-pipeline.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseMediaProcessingSpec } from "@ttt-productions/media-schemas";
|
|
2
2
|
import { createTempWorkspace } from "./workspace/temp.js";
|
|
3
3
|
import { processMedia } from "./process-media.js";
|
|
4
|
+
import { inspectMedia } from "./inspection/inspect-media.js";
|
|
4
5
|
import { mergeModeration } from "./moderation/merge.js";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { stat } from "node:fs/promises";
|
|
@@ -20,7 +21,11 @@ function attachProvider(m, provider) {
|
|
|
20
21
|
}
|
|
21
22
|
export async function runMediaPipeline(args) {
|
|
22
23
|
let { spec } = args;
|
|
23
|
-
const { io, outputBaseName = "media", moderation, signal, onProgress } = args;
|
|
24
|
+
const { io, outputBaseName = "media", moderation, signal, onProgress, resolveAfterInspection } = args;
|
|
25
|
+
// Carried onto every return path once produced — the finalizer must hand the
|
|
26
|
+
// SAME inspection object to the safety gate (one classification authority).
|
|
27
|
+
let inspection;
|
|
28
|
+
const withInspection = (r) => inspection ? { ...r, inspection } : r;
|
|
24
29
|
if (signal?.aborted) {
|
|
25
30
|
return {
|
|
26
31
|
ok: false,
|
|
@@ -53,6 +58,38 @@ export async function runMediaPipeline(args) {
|
|
|
53
58
|
onProgress?.({ phase: "read_input", percent: 0 });
|
|
54
59
|
await io.input.readToFile(inputPath);
|
|
55
60
|
onProgress?.({ phase: "read_input", percent: 1 });
|
|
61
|
+
// CANONICAL INSPECTION (before moderation and processing): classify the
|
|
62
|
+
// just-downloaded bytes structurally and let the caller's policy adapter
|
|
63
|
+
// pick the spec from the result. See RunPipelineArgs.resolveAfterInspection.
|
|
64
|
+
if (resolveAfterInspection) {
|
|
65
|
+
inspection = await inspectMedia({
|
|
66
|
+
localPath: inputPath,
|
|
67
|
+
...(signal ? { signal } : {}),
|
|
68
|
+
...(args.inspectionDeps ? { deps: args.inspectionDeps } : {}),
|
|
69
|
+
});
|
|
70
|
+
const resolution = await resolveAfterInspection(inspection);
|
|
71
|
+
if ("reject" in resolution) {
|
|
72
|
+
return withInspection({
|
|
73
|
+
ok: false,
|
|
74
|
+
mediaType: mediaTypeFromSpecKind(spec.kind),
|
|
75
|
+
error: resolution.reject,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
spec = parseMediaProcessingSpec(resolution.spec);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
return withInspection({
|
|
83
|
+
ok: false,
|
|
84
|
+
mediaType: mediaTypeFromSpecKind(spec.kind),
|
|
85
|
+
error: {
|
|
86
|
+
code: "invalid_spec",
|
|
87
|
+
message: "Invalid MediaProcessingSpec from resolveAfterInspection.",
|
|
88
|
+
details: { issues: e?.issues ?? e?.message ?? String(e) },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
56
93
|
// PRE moderation (original)
|
|
57
94
|
let mIn;
|
|
58
95
|
if (moderation?.moderateInput) {
|
|
@@ -73,7 +110,7 @@ export async function runMediaPipeline(args) {
|
|
|
73
110
|
// "error") on the result — callers route that to their retryable
|
|
74
111
|
// moderation lane and land the row failed rather than publishing it.
|
|
75
112
|
if (mIn?.status === "rejected" || mIn?.status === "error") {
|
|
76
|
-
return {
|
|
113
|
+
return withInspection({
|
|
77
114
|
ok: false,
|
|
78
115
|
mediaType: mediaTypeFromSpecKind(spec.kind),
|
|
79
116
|
moderation: mIn,
|
|
@@ -88,7 +125,7 @@ export async function runMediaPipeline(args) {
|
|
|
88
125
|
message: "Rejected by moderation.",
|
|
89
126
|
details: { provider: mIn.provider, reasons: mIn.reasons, findings: mIn.findings },
|
|
90
127
|
},
|
|
91
|
-
};
|
|
128
|
+
});
|
|
92
129
|
}
|
|
93
130
|
}
|
|
94
131
|
const result = await processMedia(spec, {
|
|
@@ -110,7 +147,7 @@ export async function runMediaPipeline(args) {
|
|
|
110
147
|
if (!o.sizeBytes)
|
|
111
148
|
o.sizeBytes = size;
|
|
112
149
|
if (size > perFileLimit) {
|
|
113
|
-
return {
|
|
150
|
+
return withInspection({
|
|
114
151
|
ok: false,
|
|
115
152
|
mediaType: mediaTypeFromSpecKind(spec.kind),
|
|
116
153
|
error: {
|
|
@@ -118,10 +155,10 @@ export async function runMediaPipeline(args) {
|
|
|
118
155
|
message: "Processed output exceeds allowed size.",
|
|
119
156
|
details: { key: o.key, sizeBytes: size, limitBytes: perFileLimit },
|
|
120
157
|
},
|
|
121
|
-
};
|
|
158
|
+
});
|
|
122
159
|
}
|
|
123
160
|
if (total > totalLimit) {
|
|
124
|
-
return {
|
|
161
|
+
return withInspection({
|
|
125
162
|
ok: false,
|
|
126
163
|
mediaType: mediaTypeFromSpecKind(spec.kind),
|
|
127
164
|
error: {
|
|
@@ -129,7 +166,7 @@ export async function runMediaPipeline(args) {
|
|
|
129
166
|
message: "Total processed outputs exceed allowed size.",
|
|
130
167
|
details: { totalBytes: total, limitBytes: totalLimit },
|
|
131
168
|
},
|
|
132
|
-
};
|
|
169
|
+
});
|
|
133
170
|
}
|
|
134
171
|
}
|
|
135
172
|
}
|
|
@@ -137,7 +174,7 @@ export async function runMediaPipeline(args) {
|
|
|
137
174
|
if (mIn)
|
|
138
175
|
result.moderation = mIn;
|
|
139
176
|
onProgress?.({ phase: "done", percent: result.ok ? 1 : 0 });
|
|
140
|
-
return result;
|
|
177
|
+
return withInspection(result);
|
|
141
178
|
}
|
|
142
179
|
// POST moderation (processed outputs) — runs on the LOCAL output paths
|
|
143
180
|
// BEFORE any final persistence, so rejected output never reaches the
|
|
@@ -159,7 +196,7 @@ export async function runMediaPipeline(args) {
|
|
|
159
196
|
// publish unscanned output. Ordering is load-bearing — see the
|
|
160
197
|
// consuming app's media-serving design docs.
|
|
161
198
|
if (mOut?.status === "rejected" || mOut?.status === "error") {
|
|
162
|
-
return {
|
|
199
|
+
return withInspection({
|
|
163
200
|
ok: false,
|
|
164
201
|
mediaType: result.mediaType,
|
|
165
202
|
moderation: mOut,
|
|
@@ -174,7 +211,7 @@ export async function runMediaPipeline(args) {
|
|
|
174
211
|
message: "Rejected by moderation.",
|
|
175
212
|
details: { provider: mOut.provider, reasons: mOut.reasons, findings: mOut.findings },
|
|
176
213
|
},
|
|
177
|
-
};
|
|
214
|
+
});
|
|
178
215
|
}
|
|
179
216
|
}
|
|
180
217
|
// Persist outputs — only after input AND output moderation both passed.
|
|
@@ -183,7 +220,7 @@ export async function runMediaPipeline(args) {
|
|
|
183
220
|
let outIdx = 0;
|
|
184
221
|
for (const out of result.outputs) {
|
|
185
222
|
if (signal?.aborted) {
|
|
186
|
-
return { ok: false, mediaType: result.mediaType, error: { code: "processing_canceled", message: "Processing canceled." } };
|
|
223
|
+
return withInspection({ ok: false, mediaType: result.mediaType, error: { code: "processing_canceled", message: "Processing canceled." } });
|
|
187
224
|
}
|
|
188
225
|
onProgress?.({ phase: "persist_outputs", percent: totalOut ? outIdx / totalOut : 0, detail: { key: out.key } });
|
|
189
226
|
if (!out.path)
|
|
@@ -200,7 +237,7 @@ export async function runMediaPipeline(args) {
|
|
|
200
237
|
if (merged)
|
|
201
238
|
result.moderation = merged;
|
|
202
239
|
onProgress?.({ phase: "done", percent: 1 });
|
|
203
|
-
return result;
|
|
240
|
+
return withInspection(result);
|
|
204
241
|
}
|
|
205
242
|
finally {
|
|
206
243
|
await ws.cleanup();
|
package/dist/run-pipeline.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-pipeline.js","sourceRoot":"","sources":["../src/run-pipeline.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n MediaProcessingResult,\n MediaProcessingSpec,\n MediaModerationResult,\n } from \"@ttt-productions/media-schemas\";\nimport { parseMediaProcessingSpec } from \"@ttt-productions/media-schemas\";\n import { createTempWorkspace } from \"./workspace/temp.js\";\n import { processMedia } from \"./process-media.js\";\n import type { MediaIO } from \"./io/types.js\";\n import type { ModerationAdapter } from \"./moderation/types.js\";\n import { mergeModeration } from \"./moderation/merge.js\";\n import path from \"node:path\";\n import { stat } from \"node:fs/promises\";\n \n export interface RunPipelineArgs {\n spec: MediaProcessingSpec;\n io: MediaIO;\n outputBaseName?: string;\n moderation?: ModerationAdapter;\n signal?: AbortSignal;\n onProgress?: (p: import(\"./types.js\").MediaPipelineProgress) => void;\n }\n \n function mediaTypeFromSpecKind(kind: MediaProcessingSpec[\"kind\"]): \"image\" | \"video\" | \"audio\" | \"other\" {\n if (kind === \"image\") return \"image\";\n if (kind === \"video\") return \"video\";\n if (kind === \"audio\") return \"audio\";\n return \"other\";\n }\n \n function attachProvider(\n m: MediaModerationResult | null | undefined,\n provider?: string\n ): MediaModerationResult | null | undefined {\n if (!m) return m;\n return { ...m, provider: m.provider ?? provider, reviewedAt: m.reviewedAt ?? Date.now() };\n }\n \n export async function runMediaPipeline(args: RunPipelineArgs): Promise<MediaProcessingResult> {\n let { spec } = args;\n const { io, outputBaseName = \"media\", moderation, signal, onProgress } = args;\n\n if (signal?.aborted) {\n return {\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n error: { code: \"processing_canceled\", message: \"Processing canceled.\" },\n };\n }\n\n // Runtime contract validation (fast-fail with actionable errors).\n try {\n spec = parseMediaProcessingSpec(spec);\n } catch (e: any) {\n return {\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n error: {\n code: \"invalid_spec\",\n message: \"Invalid MediaProcessingSpec.\",\n details: { issues: e?.issues ?? e?.message ?? String(e) },\n },\n };\n }\n \n const ws = await createTempWorkspace();\n \n try {\n const inputPath = path.join(ws.dir, \"input\");\n const outputBasePath = path.join(ws.dir, outputBaseName);\n \n if (signal?.aborted) {\n return { ok: false, mediaType: mediaTypeFromSpecKind(spec.kind), error: { code: \"processing_canceled\", message: \"Processing canceled.\" } };\n }\n onProgress?.({ phase: \"read_input\", percent: 0 });\n await io.input.readToFile(inputPath);\n onProgress?.({ phase: \"read_input\", percent: 1 });\n \n // PRE moderation (original)\n let mIn: MediaModerationResult | null | undefined;\n if (moderation?.moderateInput) {\n onProgress?.({ phase: \"moderation_input\", percent: 0 });\n mIn = attachProvider(\n await moderation.moderateInput({\n spec,\n kind: spec.kind,\n localPath: inputPath,\n mime: io.input.mime,\n }),\n moderation.provider\n );\n onProgress?.({ phase: \"moderation_input\", percent: 1 });\n \n // Fail closed on both \"rejected\" (policy violation) and \"error\" (the\n // moderation check itself did not complete — quota/outage/API failure).\n // A non-passing check must NEVER fall through to transcode + persist:\n // published bytes must always have been scanned. An \"error\" is a system\n // failure, not a content rejection, so it returns the distinct,\n // retryable `processing_failed` code AND carries `moderation` (status\n // \"error\") on the result — callers route that to their retryable\n // moderation lane and land the row failed rather than publishing it.\n if (mIn?.status === \"rejected\" || mIn?.status === \"error\") {\n return {\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n moderation: mIn,\n error:\n mIn.status === \"error\"\n ? {\n code: \"processing_failed\",\n message: \"Moderation check did not complete.\",\n details: { provider: mIn.provider, reason: \"moderation_error\" },\n }\n : {\n code: \"rejected\",\n message: \"Rejected by moderation.\",\n details: { provider: mIn.provider, reasons: mIn.reasons, findings: mIn.findings },\n },\n };\n }\n }\n \n const result = await processMedia(spec, {\n inputPath,\n outputBasePath,\n inputMime: io.input.mime,\n }, { signal, onProgress });\n\n // Output size enforcement (processed outputs)\n if (result.ok && result.outputs?.length) {\n const perFileLimit = spec.maxOutputBytes ?? spec.maxBytes ?? 200 * 1024 * 1024; // 200MB default\n const totalLimit = spec.maxTotalOutputBytes ?? spec.maxOutputBytes ?? spec.maxBytes ?? 400 * 1024 * 1024; // 400MB default\n\n let total = 0;\n for (const o of result.outputs) {\n if (!o.path) continue;\n const s = await stat(o.path);\n const size = Number(s.size ?? 0);\n total += size;\n if (!o.sizeBytes) o.sizeBytes = size;\n if (size > perFileLimit) {\n return {\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n error: {\n code: \"too_large\",\n message: \"Processed output exceeds allowed size.\",\n details: { key: o.key, sizeBytes: size, limitBytes: perFileLimit },\n },\n };\n }\n if (total > totalLimit) {\n return {\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n error: {\n code: \"too_large\",\n message: \"Total processed outputs exceed allowed size.\",\n details: { totalBytes: total, limitBytes: totalLimit },\n },\n };\n }\n }\n }\n \n if (!result.ok || !result.outputs?.length) {\n if (mIn) result.moderation = mIn;\n onProgress?.({ phase: \"done\", percent: result.ok ? 1 : 0 });\n return result;\n }\n \n // POST moderation (processed outputs) — runs on the LOCAL output paths\n // BEFORE any final persistence, so rejected output never reaches the\n // final store (R2 / emulator). Ordering is load-bearing — see\n // the consuming app's media-serving design docs.\n let mOut: MediaModerationResult | null | undefined;\n if (moderation?.moderateOutput) {\n onProgress?.({ phase: \"moderation_output\", percent: 0 });\n mOut = attachProvider(\n await moderation.moderateOutput({\n spec,\n kind: spec.kind,\n outputs: (result.outputs ?? [])\n .filter((o) => !!o.path)\n .map((o) => ({ key: o.key, localPath: o.path!, mime: o.mime })),\n }),\n moderation.provider\n );\n onProgress?.({ phase: \"moderation_output\", percent: 1 });\n\n // Same fail-closed posture as the input check: block both \"rejected\"\n // and \"error\" before any persistence, so an incomplete check can never\n // publish unscanned output. Ordering is load-bearing — see the\n // consuming app's media-serving design docs.\n if (mOut?.status === \"rejected\" || mOut?.status === \"error\") {\n return {\n ok: false,\n mediaType: result.mediaType,\n moderation: mOut,\n error:\n mOut.status === \"error\"\n ? {\n code: \"processing_failed\",\n message: \"Moderation check did not complete.\",\n details: { provider: mOut.provider, reason: \"moderation_error\" },\n }\n : {\n code: \"rejected\",\n message: \"Rejected by moderation.\",\n details: { provider: mOut.provider, reasons: mOut.reasons, findings: mOut.findings },\n },\n };\n }\n }\n\n // Persist outputs — only after input AND output moderation both passed.\n onProgress?.({ phase: \"persist_outputs\", percent: 0 });\n const totalOut = result.outputs.length;\n let outIdx = 0;\n for (const out of result.outputs) {\n if (signal?.aborted) {\n return { ok: false, mediaType: result.mediaType, error: { code: \"processing_canceled\", message: \"Processing canceled.\" } };\n }\n onProgress?.({ phase: \"persist_outputs\", percent: totalOut ? outIdx / totalOut : 0, detail: { key: out.key } });\n if (!out.path) continue;\n const persisted = await io.output.writeFromFile(out.path, out.key);\n if (persisted.url) out.url = persisted.url;\n if (persisted.path) out.path = persisted.path;\n outIdx += 1;\n }\n onProgress?.({ phase: \"persist_outputs\", percent: 1 });\n\n const merged = mergeModeration(mIn ?? undefined, mOut ?? undefined);\n if (merged) result.moderation = merged;\n\n onProgress?.({ phase: \"done\", percent: 1 });\n \n return result;\n } finally {\n await ws.cleanup();\n }\n }\n "]}
|
|
1
|
+
{"version":3,"file":"run-pipeline.js","sourceRoot":"","sources":["../src/run-pipeline.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n MediaProcessingResult,\n MediaProcessingSpec,\n MediaModerationResult,\n MediaInspectionResult,\n MediaProcessingError,\n } from \"@ttt-productions/media-schemas\";\nimport { parseMediaProcessingSpec } from \"@ttt-productions/media-schemas\";\n import { createTempWorkspace } from \"./workspace/temp.js\";\n import { processMedia } from \"./process-media.js\";\n import { inspectMedia, type InspectMediaDeps } from \"./inspection/inspect-media.js\";\n import type { MediaIO } from \"./io/types.js\";\n import type { ModerationAdapter } from \"./moderation/types.js\";\n import { mergeModeration } from \"./moderation/merge.js\";\n import path from \"node:path\";\n import { stat } from \"node:fs/promises\";\n\n export interface RunPipelineArgs {\n spec: MediaProcessingSpec;\n io: MediaIO;\n outputBaseName?: string;\n moderation?: ModerationAdapter;\n signal?: AbortSignal;\n onProgress?: (p: import(\"./types.js\").MediaPipelineProgress) => void;\n /**\n * CANONICAL INSPECTION SEAM (canonical-upload-content-classification).\n * When present, the pipeline runs `inspectMedia` on the just-downloaded temp\n * input — BEFORE moderation and processing — and calls this policy hook with\n * the bounded result. The hook either:\n * - returns `{ spec }` — the caller's policy adapter selected the\n * processing spec FROM the inspection (the byte-derived kind, never the\n * client MIME); the pipeline continues with that spec; or\n * - returns `{ reject }` — a typed terminal (`kind_mismatch`,\n * `unsupported_format`, `unsupported_codec`, …); the pipeline stops\n * before any transcode/moderation work.\n * The inspection result rides the returned `MediaProcessingResult.inspection`\n * either way, so the finalizer hands the SAME object to the safety gate —\n * one authority, no re-detection. Callers that omit the hook get the\n * pre-existing behavior unchanged.\n */\n resolveAfterInspection?: (\n inspection: MediaInspectionResult,\n ) => Promise<{ spec: MediaProcessingSpec } | { reject: MediaProcessingError }> | ({ spec: MediaProcessingSpec } | { reject: MediaProcessingError });\n /** Inspection tool overrides (tests). */\n inspectionDeps?: InspectMediaDeps;\n }\n \n function mediaTypeFromSpecKind(kind: MediaProcessingSpec[\"kind\"]): \"image\" | \"video\" | \"audio\" | \"other\" {\n if (kind === \"image\") return \"image\";\n if (kind === \"video\") return \"video\";\n if (kind === \"audio\") return \"audio\";\n return \"other\";\n }\n \n function attachProvider(\n m: MediaModerationResult | null | undefined,\n provider?: string\n ): MediaModerationResult | null | undefined {\n if (!m) return m;\n return { ...m, provider: m.provider ?? provider, reviewedAt: m.reviewedAt ?? Date.now() };\n }\n \n export async function runMediaPipeline(args: RunPipelineArgs): Promise<MediaProcessingResult> {\n let { spec } = args;\n const { io, outputBaseName = \"media\", moderation, signal, onProgress, resolveAfterInspection } = args;\n // Carried onto every return path once produced — the finalizer must hand the\n // SAME inspection object to the safety gate (one classification authority).\n let inspection: MediaInspectionResult | undefined;\n const withInspection = (r: MediaProcessingResult): MediaProcessingResult =>\n inspection ? { ...r, inspection } : r;\n\n if (signal?.aborted) {\n return {\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n error: { code: \"processing_canceled\", message: \"Processing canceled.\" },\n };\n }\n\n // Runtime contract validation (fast-fail with actionable errors).\n try {\n spec = parseMediaProcessingSpec(spec);\n } catch (e: any) {\n return {\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n error: {\n code: \"invalid_spec\",\n message: \"Invalid MediaProcessingSpec.\",\n details: { issues: e?.issues ?? e?.message ?? String(e) },\n },\n };\n }\n \n const ws = await createTempWorkspace();\n \n try {\n const inputPath = path.join(ws.dir, \"input\");\n const outputBasePath = path.join(ws.dir, outputBaseName);\n \n if (signal?.aborted) {\n return { ok: false, mediaType: mediaTypeFromSpecKind(spec.kind), error: { code: \"processing_canceled\", message: \"Processing canceled.\" } };\n }\n onProgress?.({ phase: \"read_input\", percent: 0 });\n await io.input.readToFile(inputPath);\n onProgress?.({ phase: \"read_input\", percent: 1 });\n\n // CANONICAL INSPECTION (before moderation and processing): classify the\n // just-downloaded bytes structurally and let the caller's policy adapter\n // pick the spec from the result. See RunPipelineArgs.resolveAfterInspection.\n if (resolveAfterInspection) {\n inspection = await inspectMedia({\n localPath: inputPath,\n ...(signal ? { signal } : {}),\n ...(args.inspectionDeps ? { deps: args.inspectionDeps } : {}),\n });\n const resolution = await resolveAfterInspection(inspection);\n if (\"reject\" in resolution) {\n return withInspection({\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n error: resolution.reject,\n });\n }\n try {\n spec = parseMediaProcessingSpec(resolution.spec);\n } catch (e: any) {\n return withInspection({\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n error: {\n code: \"invalid_spec\",\n message: \"Invalid MediaProcessingSpec from resolveAfterInspection.\",\n details: { issues: e?.issues ?? e?.message ?? String(e) },\n },\n });\n }\n }\n\n // PRE moderation (original)\n let mIn: MediaModerationResult | null | undefined;\n if (moderation?.moderateInput) {\n onProgress?.({ phase: \"moderation_input\", percent: 0 });\n mIn = attachProvider(\n await moderation.moderateInput({\n spec,\n kind: spec.kind,\n localPath: inputPath,\n mime: io.input.mime,\n }),\n moderation.provider\n );\n onProgress?.({ phase: \"moderation_input\", percent: 1 });\n \n // Fail closed on both \"rejected\" (policy violation) and \"error\" (the\n // moderation check itself did not complete — quota/outage/API failure).\n // A non-passing check must NEVER fall through to transcode + persist:\n // published bytes must always have been scanned. An \"error\" is a system\n // failure, not a content rejection, so it returns the distinct,\n // retryable `processing_failed` code AND carries `moderation` (status\n // \"error\") on the result — callers route that to their retryable\n // moderation lane and land the row failed rather than publishing it.\n if (mIn?.status === \"rejected\" || mIn?.status === \"error\") {\n return withInspection({\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n moderation: mIn,\n error:\n mIn.status === \"error\"\n ? {\n code: \"processing_failed\",\n message: \"Moderation check did not complete.\",\n details: { provider: mIn.provider, reason: \"moderation_error\" },\n }\n : {\n code: \"rejected\",\n message: \"Rejected by moderation.\",\n details: { provider: mIn.provider, reasons: mIn.reasons, findings: mIn.findings },\n },\n });\n }\n }\n \n const result = await processMedia(spec, {\n inputPath,\n outputBasePath,\n inputMime: io.input.mime,\n }, { signal, onProgress });\n\n // Output size enforcement (processed outputs)\n if (result.ok && result.outputs?.length) {\n const perFileLimit = spec.maxOutputBytes ?? spec.maxBytes ?? 200 * 1024 * 1024; // 200MB default\n const totalLimit = spec.maxTotalOutputBytes ?? spec.maxOutputBytes ?? spec.maxBytes ?? 400 * 1024 * 1024; // 400MB default\n\n let total = 0;\n for (const o of result.outputs) {\n if (!o.path) continue;\n const s = await stat(o.path);\n const size = Number(s.size ?? 0);\n total += size;\n if (!o.sizeBytes) o.sizeBytes = size;\n if (size > perFileLimit) {\n return withInspection({\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n error: {\n code: \"too_large\",\n message: \"Processed output exceeds allowed size.\",\n details: { key: o.key, sizeBytes: size, limitBytes: perFileLimit },\n },\n });\n }\n if (total > totalLimit) {\n return withInspection({\n ok: false,\n mediaType: mediaTypeFromSpecKind(spec.kind),\n error: {\n code: \"too_large\",\n message: \"Total processed outputs exceed allowed size.\",\n details: { totalBytes: total, limitBytes: totalLimit },\n },\n });\n }\n }\n }\n \n if (!result.ok || !result.outputs?.length) {\n if (mIn) result.moderation = mIn;\n onProgress?.({ phase: \"done\", percent: result.ok ? 1 : 0 });\n return withInspection(result);\n }\n \n // POST moderation (processed outputs) — runs on the LOCAL output paths\n // BEFORE any final persistence, so rejected output never reaches the\n // final store (R2 / emulator). Ordering is load-bearing — see\n // the consuming app's media-serving design docs.\n let mOut: MediaModerationResult | null | undefined;\n if (moderation?.moderateOutput) {\n onProgress?.({ phase: \"moderation_output\", percent: 0 });\n mOut = attachProvider(\n await moderation.moderateOutput({\n spec,\n kind: spec.kind,\n outputs: (result.outputs ?? [])\n .filter((o) => !!o.path)\n .map((o) => ({ key: o.key, localPath: o.path!, mime: o.mime })),\n }),\n moderation.provider\n );\n onProgress?.({ phase: \"moderation_output\", percent: 1 });\n\n // Same fail-closed posture as the input check: block both \"rejected\"\n // and \"error\" before any persistence, so an incomplete check can never\n // publish unscanned output. Ordering is load-bearing — see the\n // consuming app's media-serving design docs.\n if (mOut?.status === \"rejected\" || mOut?.status === \"error\") {\n return withInspection({\n ok: false,\n mediaType: result.mediaType,\n moderation: mOut,\n error:\n mOut.status === \"error\"\n ? {\n code: \"processing_failed\",\n message: \"Moderation check did not complete.\",\n details: { provider: mOut.provider, reason: \"moderation_error\" },\n }\n : {\n code: \"rejected\",\n message: \"Rejected by moderation.\",\n details: { provider: mOut.provider, reasons: mOut.reasons, findings: mOut.findings },\n },\n });\n }\n }\n\n // Persist outputs — only after input AND output moderation both passed.\n onProgress?.({ phase: \"persist_outputs\", percent: 0 });\n const totalOut = result.outputs.length;\n let outIdx = 0;\n for (const out of result.outputs) {\n if (signal?.aborted) {\n return withInspection({ ok: false, mediaType: result.mediaType, error: { code: \"processing_canceled\", message: \"Processing canceled.\" } });\n }\n onProgress?.({ phase: \"persist_outputs\", percent: totalOut ? outIdx / totalOut : 0, detail: { key: out.key } });\n if (!out.path) continue;\n const persisted = await io.output.writeFromFile(out.path, out.key);\n if (persisted.url) out.url = persisted.url;\n if (persisted.path) out.path = persisted.path;\n outIdx += 1;\n }\n onProgress?.({ phase: \"persist_outputs\", percent: 1 });\n\n const merged = mergeModeration(mIn ?? undefined, mOut ?? undefined);\n if (merged) result.moderation = merged;\n\n onProgress?.({ phase: \"done\", percent: 1 });\n \n return withInspection(result);\n } finally {\n await ws.cleanup();\n }\n }\n "]}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** TOTAL attempts (initial + retries) — three totals means at most TWO delays. */
|
|
2
|
+
export declare const R2_REQUEST_MAX_ATTEMPTS = 3;
|
|
3
|
+
/** Base delay for full-jitter exponential backoff. */
|
|
4
|
+
export declare const R2_RETRY_BASE_DELAY_MS = 250;
|
|
5
|
+
/** Ceiling on the exponential backoff (before jitter). */
|
|
6
|
+
export declare const R2_RETRY_MAX_BACKOFF_MS = 5000;
|
|
7
|
+
/** Hard cap on an honored server `Retry-After` so one response can't eat the
|
|
8
|
+
* caller's whole timeout budget. */
|
|
9
|
+
export declare const R2_RETRY_AFTER_CAP_MS = 30000;
|
|
10
|
+
/** HTTP statuses treated as transient and safe to retry. */
|
|
11
|
+
export declare const R2_RETRYABLE_STATUSES: ReadonlySet<number>;
|
|
12
|
+
export type R2RetryableOperation = "putFile" | "copy" | "readToFile";
|
|
13
|
+
/** Deterministic seams for tests; production defaults are internal (see below). */
|
|
14
|
+
export interface R2RetrySeams {
|
|
15
|
+
/** Resolve after `ms` milliseconds. */
|
|
16
|
+
sleep: (ms: number) => Promise<void>;
|
|
17
|
+
/** Uniform random in [0, 1). */
|
|
18
|
+
random: () => number;
|
|
19
|
+
/** Epoch-millisecond clock — keeps HTTP-date `Retry-After` tests off wall-clock. */
|
|
20
|
+
now: () => number;
|
|
21
|
+
}
|
|
22
|
+
/** Production seam defaults. */
|
|
23
|
+
export declare function defaultR2RetrySeams(): R2RetrySeams;
|
|
24
|
+
/**
|
|
25
|
+
* True only for recognized transient transport failures. An explicit
|
|
26
|
+
* `AbortError` and deterministic local-filesystem errors always return false —
|
|
27
|
+
* an abort must never become another attempt.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isRetryableTransportError(err: unknown): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Parse a `Retry-After` header value into a non-negative millisecond delay.
|
|
32
|
+
* Supports both the delta-seconds form (`"120"`) and the HTTP-date form
|
|
33
|
+
* (`"Wed, 21 Oct 2015 07:28:00 GMT"`). Returns null when absent/unparseable.
|
|
34
|
+
* An HTTP-date in the past clamps to 0.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseRetryAfterMs(headerValue: string | null | undefined, nowMs: number): number | null;
|
|
37
|
+
/**
|
|
38
|
+
* Compute the pre-next-attempt delay. If the failed response carries a valid
|
|
39
|
+
* `Retry-After`, honor it (capped, no backoff added on top). Otherwise use
|
|
40
|
+
* full-jitter exponential backoff: `random(0, min(maxBackoff, base * 2^retryIndex))`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function computeR2DelayMs(params: {
|
|
43
|
+
response: Response | null;
|
|
44
|
+
/** 0-based index of the retry about to happen (0 for the first retry). */
|
|
45
|
+
retryIndex: number;
|
|
46
|
+
seams: R2RetrySeams;
|
|
47
|
+
}): number;
|
|
48
|
+
/**
|
|
49
|
+
* Thrown when an R2 operation exhausts its retries or hits a non-retryable
|
|
50
|
+
* response. Carries operation/bucket/key/attempt/status context and a
|
|
51
|
+
* bounded, sanitized response body — never credentials, authorization headers,
|
|
52
|
+
* or full signed URLs.
|
|
53
|
+
*/
|
|
54
|
+
export declare class R2StorageError extends Error {
|
|
55
|
+
readonly operation: R2RetryableOperation;
|
|
56
|
+
readonly bucket: string;
|
|
57
|
+
readonly key: string;
|
|
58
|
+
readonly attempts: number;
|
|
59
|
+
readonly status?: number;
|
|
60
|
+
readonly responseText?: string;
|
|
61
|
+
constructor(params: {
|
|
62
|
+
operation: R2RetryableOperation;
|
|
63
|
+
bucket: string;
|
|
64
|
+
key: string;
|
|
65
|
+
attempts: number;
|
|
66
|
+
status?: number;
|
|
67
|
+
responseText?: string;
|
|
68
|
+
cause?: unknown;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export type R2AttemptResult<T> = {
|
|
72
|
+
kind: "success";
|
|
73
|
+
value: T;
|
|
74
|
+
} | {
|
|
75
|
+
kind: "response";
|
|
76
|
+
response: Response;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Drive up to `R2_REQUEST_MAX_ATTEMPTS` attempts of a single storage operation.
|
|
80
|
+
* `runAttempt` builds a FRESH signed request each call and returns either a
|
|
81
|
+
* success value or the raw `Response` for the driver to classify. Thrown errors
|
|
82
|
+
* are classified: only recognized transient transport failures are retried.
|
|
83
|
+
*/
|
|
84
|
+
export declare function fetchWithR2Retry<T>(params: {
|
|
85
|
+
operation: R2RetryableOperation;
|
|
86
|
+
bucket: string;
|
|
87
|
+
key: string;
|
|
88
|
+
seams: R2RetrySeams;
|
|
89
|
+
runAttempt: (attemptNumber: number) => Promise<R2AttemptResult<T>>;
|
|
90
|
+
}): Promise<T>;
|
|
91
|
+
//# sourceMappingURL=r2-retry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r2-retry.d.ts","sourceRoot":"","sources":["../../src/server/r2-retry.ts"],"names":[],"mappings":"AAgBA,kFAAkF;AAClF,eAAO,MAAM,uBAAuB,IAAI,CAAC;AACzC,sDAAsD;AACtD,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAC1C,0DAA0D;AAC1D,eAAO,MAAM,uBAAuB,OAAQ,CAAC;AAC7C;qCACqC;AACrC,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAE5C,4DAA4D;AAC5D,eAAO,MAAM,qBAAqB,EAAE,WAAW,CAAC,MAAM,CAA2C,CAAC;AAElG,MAAM,MAAM,oBAAoB,GAAG,SAAS,GAAG,MAAM,GAAG,YAAY,CAAC;AAErE,mFAAmF;AACnF,MAAM,WAAW,YAAY;IAC3B,uCAAuC;IACvC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,gCAAgC;IAChC,MAAM,EAAE,MAAM,MAAM,CAAC;IACrB,oFAAoF;IACpF,GAAG,EAAE,MAAM,MAAM,CAAC;CACnB;AAED,gCAAgC;AAChC,wBAAgB,mBAAmB,IAAI,YAAY,CAMlD;AAsDD;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAwB/D;AAMD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiBtG;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE;IACvC,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAC;IAC1B,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,YAAY,CAAC;CACrB,GAAG,MAAM,CAYT;AAMD;;;;;GAKG;AACH,qBAAa,cAAe,SAAQ,KAAK;IACvC,QAAQ,CAAC,SAAS,EAAE,oBAAoB,CAAC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;gBAEnB,MAAM,EAAE;QAClB,SAAS,EAAE,oBAAoB,CAAC;QAChC,MAAM,EAAE,MAAM,CAAC;QACf,GAAG,EAAE,MAAM,CAAC;QACZ,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,KAAK,CAAC,EAAE,OAAO,CAAC;KACjB;CAeF;AAMD,MAAM,MAAM,eAAe,CAAC,CAAC,IACzB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC;AAyB7C;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,CAAC,EAAE,MAAM,EAAE;IAChD,SAAS,EAAE,oBAAoB,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,YAAY,CAAC;IACpB,UAAU,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC;CACpE,GAAG,OAAO,CAAC,CAAC,CAAC,CAoCb"}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Bounded transient-retry policy for the R2 object store.
|
|
2
|
+
//
|
|
3
|
+
// PRIVATE sibling of storage-ops.ts — imported ONLY by that module. It is
|
|
4
|
+
// deliberately NOT re-exported from the server barrel (`server/index.ts`); the
|
|
5
|
+
// only public surface is the `R2StorageError` type, which storage-ops re-exports
|
|
6
|
+
// so callers can recognize an exhausted storage operation.
|
|
7
|
+
//
|
|
8
|
+
// The policy applies to the idempotent-against-a-deterministic-key operations
|
|
9
|
+
// only (putFile, copy, readToFile). `delete` is intentionally one-shot and never
|
|
10
|
+
// routes through here. Signing/config failures, local filesystem errors, and
|
|
11
|
+
// deterministic HTTP 4xx responses are never retried.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Fixed internal policy — never exposed as tunables on the public factory.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/** TOTAL attempts (initial + retries) — three totals means at most TWO delays. */
|
|
16
|
+
export const R2_REQUEST_MAX_ATTEMPTS = 3;
|
|
17
|
+
/** Base delay for full-jitter exponential backoff. */
|
|
18
|
+
export const R2_RETRY_BASE_DELAY_MS = 250;
|
|
19
|
+
/** Ceiling on the exponential backoff (before jitter). */
|
|
20
|
+
export const R2_RETRY_MAX_BACKOFF_MS = 5_000;
|
|
21
|
+
/** Hard cap on an honored server `Retry-After` so one response can't eat the
|
|
22
|
+
* caller's whole timeout budget. */
|
|
23
|
+
export const R2_RETRY_AFTER_CAP_MS = 30_000;
|
|
24
|
+
/** HTTP statuses treated as transient and safe to retry. */
|
|
25
|
+
export const R2_RETRYABLE_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
26
|
+
/** Production seam defaults. */
|
|
27
|
+
export function defaultR2RetrySeams() {
|
|
28
|
+
return {
|
|
29
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
30
|
+
random: Math.random,
|
|
31
|
+
now: Date.now,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Transient-error classification
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Recognized transient transport failure codes (undici/fetch surface these on
|
|
38
|
+
// the error or its `.cause`). A broken connection to R2 lands here.
|
|
39
|
+
const RETRYABLE_NETWORK_ERROR_CODES = new Set([
|
|
40
|
+
"ECONNRESET",
|
|
41
|
+
"ECONNREFUSED",
|
|
42
|
+
"ECONNABORTED",
|
|
43
|
+
"ETIMEDOUT",
|
|
44
|
+
"EPIPE",
|
|
45
|
+
"ENOTFOUND",
|
|
46
|
+
"EAI_AGAIN",
|
|
47
|
+
"ENETUNREACH",
|
|
48
|
+
"ENETDOWN",
|
|
49
|
+
"EHOSTUNREACH",
|
|
50
|
+
"EHOSTDOWN",
|
|
51
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
52
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
53
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
54
|
+
"UND_ERR_SOCKET",
|
|
55
|
+
]);
|
|
56
|
+
// Deterministic local filesystem errors are NEVER retried (disk full, perms,
|
|
57
|
+
// missing dir, etc.). This guard wins over any transport classification so a
|
|
58
|
+
// local write failure during readToFile piping can't be mistaken for transient.
|
|
59
|
+
const LOCAL_FS_ERROR_CODES = new Set([
|
|
60
|
+
"ENOSPC",
|
|
61
|
+
"EACCES",
|
|
62
|
+
"EISDIR",
|
|
63
|
+
"ENOENT",
|
|
64
|
+
"EROFS",
|
|
65
|
+
"EMFILE",
|
|
66
|
+
"ENFILE",
|
|
67
|
+
"EBADF",
|
|
68
|
+
"EEXIST",
|
|
69
|
+
"EPERM",
|
|
70
|
+
"ENAMETOOLONG",
|
|
71
|
+
"ELOOP",
|
|
72
|
+
"ENOTDIR",
|
|
73
|
+
"EDQUOT",
|
|
74
|
+
]);
|
|
75
|
+
function codeOf(err) {
|
|
76
|
+
if (err && typeof err === "object") {
|
|
77
|
+
const c = err.code;
|
|
78
|
+
if (typeof c === "string")
|
|
79
|
+
return c;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* True only for recognized transient transport failures. An explicit
|
|
85
|
+
* `AbortError` and deterministic local-filesystem errors always return false —
|
|
86
|
+
* an abort must never become another attempt.
|
|
87
|
+
*/
|
|
88
|
+
export function isRetryableTransportError(err) {
|
|
89
|
+
if (!err || typeof err !== "object")
|
|
90
|
+
return false;
|
|
91
|
+
const e = err;
|
|
92
|
+
// NEVER convert an explicit abort into another attempt.
|
|
93
|
+
if (e.name === "AbortError")
|
|
94
|
+
return false;
|
|
95
|
+
const selfCode = codeOf(err);
|
|
96
|
+
const causeCode = codeOf(e.cause);
|
|
97
|
+
// Deterministic local filesystem errors are not transient — decided first so a
|
|
98
|
+
// local write error can never be reclassified as a transport failure.
|
|
99
|
+
if (selfCode && LOCAL_FS_ERROR_CODES.has(selfCode))
|
|
100
|
+
return false;
|
|
101
|
+
if (causeCode && LOCAL_FS_ERROR_CODES.has(causeCode))
|
|
102
|
+
return false;
|
|
103
|
+
// Recognized transient network codes on the error or its cause.
|
|
104
|
+
if (selfCode && RETRYABLE_NETWORK_ERROR_CODES.has(selfCode))
|
|
105
|
+
return true;
|
|
106
|
+
if (causeCode && RETRYABLE_NETWORK_ERROR_CODES.has(causeCode))
|
|
107
|
+
return true;
|
|
108
|
+
// fetch()/undici surface transport failures as `TypeError: fetch failed`
|
|
109
|
+
// (often with an unrecognized or absent cause) — treat those as transient.
|
|
110
|
+
if (err instanceof TypeError || e.name === "TypeError")
|
|
111
|
+
return true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Retry-After parsing + delay computation
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
/**
|
|
118
|
+
* Parse a `Retry-After` header value into a non-negative millisecond delay.
|
|
119
|
+
* Supports both the delta-seconds form (`"120"`) and the HTTP-date form
|
|
120
|
+
* (`"Wed, 21 Oct 2015 07:28:00 GMT"`). Returns null when absent/unparseable.
|
|
121
|
+
* An HTTP-date in the past clamps to 0.
|
|
122
|
+
*/
|
|
123
|
+
export function parseRetryAfterMs(headerValue, nowMs) {
|
|
124
|
+
if (headerValue == null)
|
|
125
|
+
return null;
|
|
126
|
+
const trimmed = headerValue.trim();
|
|
127
|
+
if (trimmed === "")
|
|
128
|
+
return null;
|
|
129
|
+
// delta-seconds form
|
|
130
|
+
if (/^\d+$/.test(trimmed)) {
|
|
131
|
+
const seconds = Number(trimmed);
|
|
132
|
+
if (!Number.isFinite(seconds) || seconds < 0)
|
|
133
|
+
return null;
|
|
134
|
+
return seconds * 1000;
|
|
135
|
+
}
|
|
136
|
+
// HTTP-date form
|
|
137
|
+
const dateMs = Date.parse(trimmed);
|
|
138
|
+
if (Number.isNaN(dateMs))
|
|
139
|
+
return null;
|
|
140
|
+
const delta = dateMs - nowMs;
|
|
141
|
+
return delta > 0 ? delta : 0;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Compute the pre-next-attempt delay. If the failed response carries a valid
|
|
145
|
+
* `Retry-After`, honor it (capped, no backoff added on top). Otherwise use
|
|
146
|
+
* full-jitter exponential backoff: `random(0, min(maxBackoff, base * 2^retryIndex))`.
|
|
147
|
+
*/
|
|
148
|
+
export function computeR2DelayMs(params) {
|
|
149
|
+
const { response, retryIndex, seams } = params;
|
|
150
|
+
if (response) {
|
|
151
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"), seams.now());
|
|
152
|
+
if (retryAfterMs !== null) {
|
|
153
|
+
return Math.min(retryAfterMs, R2_RETRY_AFTER_CAP_MS);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const ceiling = Math.min(R2_RETRY_MAX_BACKOFF_MS, R2_RETRY_BASE_DELAY_MS * 2 ** retryIndex);
|
|
157
|
+
return Math.floor(seams.random() * ceiling);
|
|
158
|
+
}
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Exhaustion error
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
/**
|
|
163
|
+
* Thrown when an R2 operation exhausts its retries or hits a non-retryable
|
|
164
|
+
* response. Carries operation/bucket/key/attempt/status context and a
|
|
165
|
+
* bounded, sanitized response body — never credentials, authorization headers,
|
|
166
|
+
* or full signed URLs.
|
|
167
|
+
*/
|
|
168
|
+
export class R2StorageError extends Error {
|
|
169
|
+
operation;
|
|
170
|
+
bucket;
|
|
171
|
+
key;
|
|
172
|
+
attempts;
|
|
173
|
+
status;
|
|
174
|
+
responseText;
|
|
175
|
+
constructor(params) {
|
|
176
|
+
const statusPart = params.status !== undefined ? ` status ${params.status}` : "";
|
|
177
|
+
const bodyPart = params.responseText ? ` ${params.responseText}` : "";
|
|
178
|
+
super(`R2 ${params.operation} failed for ${params.bucket}/${params.key} after ${params.attempts} attempt(s):${statusPart}${bodyPart}`.trim(), params.cause !== undefined ? { cause: params.cause } : undefined);
|
|
179
|
+
this.name = "R2StorageError";
|
|
180
|
+
this.operation = params.operation;
|
|
181
|
+
this.bucket = params.bucket;
|
|
182
|
+
this.key = params.key;
|
|
183
|
+
this.attempts = params.attempts;
|
|
184
|
+
this.status = params.status;
|
|
185
|
+
this.responseText = params.responseText;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Strip ASCII control characters (0x00-0x1F, 0x7F) and bound length. Done with a
|
|
189
|
+
// code-point filter rather than a control-character regex (which `no-control-regex`
|
|
190
|
+
// forbids and which would embed literal control bytes in source).
|
|
191
|
+
function sanitizeBodyText(text) {
|
|
192
|
+
let out = "";
|
|
193
|
+
for (const ch of text.slice(0, 1000)) {
|
|
194
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
195
|
+
out += code < 0x20 || code === 0x7f ? " " : ch;
|
|
196
|
+
}
|
|
197
|
+
return out.trim().slice(0, 500);
|
|
198
|
+
}
|
|
199
|
+
// Bounded, sanitized read of a failed response body. Also drains the body so
|
|
200
|
+
// the underlying socket can be reused before the next attempt. R2 error bodies
|
|
201
|
+
// are short XML and never contain our credentials; sanitize defensively regardless.
|
|
202
|
+
async function safeReadBoundedBody(res) {
|
|
203
|
+
try {
|
|
204
|
+
return sanitizeBodyText(await res.text());
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return "<no body>";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Drive up to `R2_REQUEST_MAX_ATTEMPTS` attempts of a single storage operation.
|
|
212
|
+
* `runAttempt` builds a FRESH signed request each call and returns either a
|
|
213
|
+
* success value or the raw `Response` for the driver to classify. Thrown errors
|
|
214
|
+
* are classified: only recognized transient transport failures are retried.
|
|
215
|
+
*/
|
|
216
|
+
export async function fetchWithR2Retry(params) {
|
|
217
|
+
const { operation, bucket, key, seams, runAttempt } = params;
|
|
218
|
+
for (let attempt = 1; attempt <= R2_REQUEST_MAX_ATTEMPTS; attempt++) {
|
|
219
|
+
let result;
|
|
220
|
+
try {
|
|
221
|
+
result = await runAttempt(attempt);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
// Signing failures, explicit aborts, and deterministic local-fs errors are
|
|
225
|
+
// never retried; only recognized transient transport failures are.
|
|
226
|
+
if (!isRetryableTransportError(err))
|
|
227
|
+
throw err;
|
|
228
|
+
if (attempt >= R2_REQUEST_MAX_ATTEMPTS) {
|
|
229
|
+
throw new R2StorageError({ operation, bucket, key, attempts: attempt, cause: err });
|
|
230
|
+
}
|
|
231
|
+
await seams.sleep(computeR2DelayMs({ response: null, retryIndex: attempt - 1, seams }));
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (result.kind === "success")
|
|
235
|
+
return result.value;
|
|
236
|
+
const response = result.response;
|
|
237
|
+
const status = response.status;
|
|
238
|
+
// Consume the failed body before the next attempt.
|
|
239
|
+
const responseText = await safeReadBoundedBody(response);
|
|
240
|
+
if (R2_RETRYABLE_STATUSES.has(status) && attempt < R2_REQUEST_MAX_ATTEMPTS) {
|
|
241
|
+
await seams.sleep(computeR2DelayMs({ response, retryIndex: attempt - 1, seams }));
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Non-retryable status, or a retryable status with attempts exhausted.
|
|
245
|
+
throw new R2StorageError({ operation, bucket, key, attempts: attempt, status, responseText });
|
|
246
|
+
}
|
|
247
|
+
// Unreachable: the loop always returns or throws.
|
|
248
|
+
throw new R2StorageError({ operation, bucket, key, attempts: R2_REQUEST_MAX_ATTEMPTS });
|
|
249
|
+
}
|
|
250
|
+
//# sourceMappingURL=r2-retry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r2-retry.js","sourceRoot":"","sources":["../../src/server/r2-retry.ts"],"names":[],"mappings":"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","sourcesContent":["// Bounded transient-retry policy for the R2 object store.\n//\n// PRIVATE sibling of storage-ops.ts — imported ONLY by that module. It is\n// deliberately NOT re-exported from the server barrel (`server/index.ts`); the\n// only public surface is the `R2StorageError` type, which storage-ops re-exports\n// so callers can recognize an exhausted storage operation.\n//\n// The policy applies to the idempotent-against-a-deterministic-key operations\n// only (putFile, copy, readToFile). `delete` is intentionally one-shot and never\n// routes through here. Signing/config failures, local filesystem errors, and\n// deterministic HTTP 4xx responses are never retried.\n\n// ---------------------------------------------------------------------------\n// Fixed internal policy — never exposed as tunables on the public factory.\n// ---------------------------------------------------------------------------\n\n/** TOTAL attempts (initial + retries) — three totals means at most TWO delays. */\nexport const R2_REQUEST_MAX_ATTEMPTS = 3;\n/** Base delay for full-jitter exponential backoff. */\nexport const R2_RETRY_BASE_DELAY_MS = 250;\n/** Ceiling on the exponential backoff (before jitter). */\nexport const R2_RETRY_MAX_BACKOFF_MS = 5_000;\n/** Hard cap on an honored server `Retry-After` so one response can't eat the\n * caller's whole timeout budget. */\nexport const R2_RETRY_AFTER_CAP_MS = 30_000;\n\n/** HTTP statuses treated as transient and safe to retry. */\nexport const R2_RETRYABLE_STATUSES: ReadonlySet<number> = new Set([408, 429, 500, 502, 503, 504]);\n\nexport type R2RetryableOperation = \"putFile\" | \"copy\" | \"readToFile\";\n\n/** Deterministic seams for tests; production defaults are internal (see below). */\nexport interface R2RetrySeams {\n /** Resolve after `ms` milliseconds. */\n sleep: (ms: number) => Promise<void>;\n /** Uniform random in [0, 1). */\n random: () => number;\n /** Epoch-millisecond clock — keeps HTTP-date `Retry-After` tests off wall-clock. */\n now: () => number;\n}\n\n/** Production seam defaults. */\nexport function defaultR2RetrySeams(): R2RetrySeams {\n return {\n sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),\n random: Math.random,\n now: Date.now,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Transient-error classification\n// ---------------------------------------------------------------------------\n\n// Recognized transient transport failure codes (undici/fetch surface these on\n// the error or its `.cause`). A broken connection to R2 lands here.\nconst RETRYABLE_NETWORK_ERROR_CODES: ReadonlySet<string> = new Set([\n \"ECONNRESET\",\n \"ECONNREFUSED\",\n \"ECONNABORTED\",\n \"ETIMEDOUT\",\n \"EPIPE\",\n \"ENOTFOUND\",\n \"EAI_AGAIN\",\n \"ENETUNREACH\",\n \"ENETDOWN\",\n \"EHOSTUNREACH\",\n \"EHOSTDOWN\",\n \"UND_ERR_CONNECT_TIMEOUT\",\n \"UND_ERR_HEADERS_TIMEOUT\",\n \"UND_ERR_BODY_TIMEOUT\",\n \"UND_ERR_SOCKET\",\n]);\n\n// Deterministic local filesystem errors are NEVER retried (disk full, perms,\n// missing dir, etc.). This guard wins over any transport classification so a\n// local write failure during readToFile piping can't be mistaken for transient.\nconst LOCAL_FS_ERROR_CODES: ReadonlySet<string> = new Set([\n \"ENOSPC\",\n \"EACCES\",\n \"EISDIR\",\n \"ENOENT\",\n \"EROFS\",\n \"EMFILE\",\n \"ENFILE\",\n \"EBADF\",\n \"EEXIST\",\n \"EPERM\",\n \"ENAMETOOLONG\",\n \"ELOOP\",\n \"ENOTDIR\",\n \"EDQUOT\",\n]);\n\nfunction codeOf(err: unknown): string | undefined {\n if (err && typeof err === \"object\") {\n const c = (err as { code?: unknown }).code;\n if (typeof c === \"string\") return c;\n }\n return undefined;\n}\n\n/**\n * True only for recognized transient transport failures. An explicit\n * `AbortError` and deterministic local-filesystem errors always return false —\n * an abort must never become another attempt.\n */\nexport function isRetryableTransportError(err: unknown): boolean {\n if (!err || typeof err !== \"object\") return false;\n const e = err as { name?: unknown; cause?: unknown };\n\n // NEVER convert an explicit abort into another attempt.\n if (e.name === \"AbortError\") return false;\n\n const selfCode = codeOf(err);\n const causeCode = codeOf(e.cause);\n\n // Deterministic local filesystem errors are not transient — decided first so a\n // local write error can never be reclassified as a transport failure.\n if (selfCode && LOCAL_FS_ERROR_CODES.has(selfCode)) return false;\n if (causeCode && LOCAL_FS_ERROR_CODES.has(causeCode)) return false;\n\n // Recognized transient network codes on the error or its cause.\n if (selfCode && RETRYABLE_NETWORK_ERROR_CODES.has(selfCode)) return true;\n if (causeCode && RETRYABLE_NETWORK_ERROR_CODES.has(causeCode)) return true;\n\n // fetch()/undici surface transport failures as `TypeError: fetch failed`\n // (often with an unrecognized or absent cause) — treat those as transient.\n if (err instanceof TypeError || e.name === \"TypeError\") return true;\n\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Retry-After parsing + delay computation\n// ---------------------------------------------------------------------------\n\n/**\n * Parse a `Retry-After` header value into a non-negative millisecond delay.\n * Supports both the delta-seconds form (`\"120\"`) and the HTTP-date form\n * (`\"Wed, 21 Oct 2015 07:28:00 GMT\"`). Returns null when absent/unparseable.\n * An HTTP-date in the past clamps to 0.\n */\nexport function parseRetryAfterMs(headerValue: string | null | undefined, nowMs: number): number | null {\n if (headerValue == null) return null;\n const trimmed = headerValue.trim();\n if (trimmed === \"\") return null;\n\n // delta-seconds form\n if (/^\\d+$/.test(trimmed)) {\n const seconds = Number(trimmed);\n if (!Number.isFinite(seconds) || seconds < 0) return null;\n return seconds * 1000;\n }\n\n // HTTP-date form\n const dateMs = Date.parse(trimmed);\n if (Number.isNaN(dateMs)) return null;\n const delta = dateMs - nowMs;\n return delta > 0 ? delta : 0;\n}\n\n/**\n * Compute the pre-next-attempt delay. If the failed response carries a valid\n * `Retry-After`, honor it (capped, no backoff added on top). Otherwise use\n * full-jitter exponential backoff: `random(0, min(maxBackoff, base * 2^retryIndex))`.\n */\nexport function computeR2DelayMs(params: {\n response: Response | null;\n /** 0-based index of the retry about to happen (0 for the first retry). */\n retryIndex: number;\n seams: R2RetrySeams;\n}): number {\n const { response, retryIndex, seams } = params;\n\n if (response) {\n const retryAfterMs = parseRetryAfterMs(response.headers.get(\"retry-after\"), seams.now());\n if (retryAfterMs !== null) {\n return Math.min(retryAfterMs, R2_RETRY_AFTER_CAP_MS);\n }\n }\n\n const ceiling = Math.min(R2_RETRY_MAX_BACKOFF_MS, R2_RETRY_BASE_DELAY_MS * 2 ** retryIndex);\n return Math.floor(seams.random() * ceiling);\n}\n\n// ---------------------------------------------------------------------------\n// Exhaustion error\n// ---------------------------------------------------------------------------\n\n/**\n * Thrown when an R2 operation exhausts its retries or hits a non-retryable\n * response. Carries operation/bucket/key/attempt/status context and a\n * bounded, sanitized response body — never credentials, authorization headers,\n * or full signed URLs.\n */\nexport class R2StorageError extends Error {\n readonly operation: R2RetryableOperation;\n readonly bucket: string;\n readonly key: string;\n readonly attempts: number;\n readonly status?: number;\n readonly responseText?: string;\n\n constructor(params: {\n operation: R2RetryableOperation;\n bucket: string;\n key: string;\n attempts: number;\n status?: number;\n responseText?: string;\n cause?: unknown;\n }) {\n const statusPart = params.status !== undefined ? ` status ${params.status}` : \"\";\n const bodyPart = params.responseText ? ` ${params.responseText}` : \"\";\n super(\n `R2 ${params.operation} failed for ${params.bucket}/${params.key} after ${params.attempts} attempt(s):${statusPart}${bodyPart}`.trim(),\n params.cause !== undefined ? { cause: params.cause } : undefined,\n );\n this.name = \"R2StorageError\";\n this.operation = params.operation;\n this.bucket = params.bucket;\n this.key = params.key;\n this.attempts = params.attempts;\n this.status = params.status;\n this.responseText = params.responseText;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Retry driver\n// ---------------------------------------------------------------------------\n\nexport type R2AttemptResult<T> =\n | { kind: \"success\"; value: T }\n | { kind: \"response\"; response: Response };\n\n// Strip ASCII control characters (0x00-0x1F, 0x7F) and bound length. Done with a\n// code-point filter rather than a control-character regex (which `no-control-regex`\n// forbids and which would embed literal control bytes in source).\nfunction sanitizeBodyText(text: string): string {\n let out = \"\";\n for (const ch of text.slice(0, 1000)) {\n const code = ch.codePointAt(0) ?? 0;\n out += code < 0x20 || code === 0x7f ? \" \" : ch;\n }\n return out.trim().slice(0, 500);\n}\n\n// Bounded, sanitized read of a failed response body. Also drains the body so\n// the underlying socket can be reused before the next attempt. R2 error bodies\n// are short XML and never contain our credentials; sanitize defensively regardless.\nasync function safeReadBoundedBody(res: Response): Promise<string> {\n try {\n return sanitizeBodyText(await res.text());\n } catch {\n return \"<no body>\";\n }\n}\n\n/**\n * Drive up to `R2_REQUEST_MAX_ATTEMPTS` attempts of a single storage operation.\n * `runAttempt` builds a FRESH signed request each call and returns either a\n * success value or the raw `Response` for the driver to classify. Thrown errors\n * are classified: only recognized transient transport failures are retried.\n */\nexport async function fetchWithR2Retry<T>(params: {\n operation: R2RetryableOperation;\n bucket: string;\n key: string;\n seams: R2RetrySeams;\n runAttempt: (attemptNumber: number) => Promise<R2AttemptResult<T>>;\n}): Promise<T> {\n const { operation, bucket, key, seams, runAttempt } = params;\n\n for (let attempt = 1; attempt <= R2_REQUEST_MAX_ATTEMPTS; attempt++) {\n let result: R2AttemptResult<T>;\n try {\n result = await runAttempt(attempt);\n } catch (err) {\n // Signing failures, explicit aborts, and deterministic local-fs errors are\n // never retried; only recognized transient transport failures are.\n if (!isRetryableTransportError(err)) throw err;\n if (attempt >= R2_REQUEST_MAX_ATTEMPTS) {\n throw new R2StorageError({ operation, bucket, key, attempts: attempt, cause: err });\n }\n await seams.sleep(computeR2DelayMs({ response: null, retryIndex: attempt - 1, seams }));\n continue;\n }\n\n if (result.kind === \"success\") return result.value;\n\n const response = result.response;\n const status = response.status;\n // Consume the failed body before the next attempt.\n const responseText = await safeReadBoundedBody(response);\n\n if (R2_RETRYABLE_STATUSES.has(status) && attempt < R2_REQUEST_MAX_ATTEMPTS) {\n await seams.sleep(computeR2DelayMs({ response, retryIndex: attempt - 1, seams }));\n continue;\n }\n\n // Non-retryable status, or a retryable status with attempts exhausted.\n throw new R2StorageError({ operation, bucket, key, attempts: attempt, status, responseText });\n }\n\n // Unreachable: the loop always returns or throws.\n throw new R2StorageError({ operation, bucket, key, attempts: R2_REQUEST_MAX_ATTEMPTS });\n}\n"]}
|