1/** 2 * apps/worker — files pipeline error classification (TASK-0102 §1). 3 * 4 * Every step failure is classified as: 5 * 6 * - RETRYABLE — transient: network errors, S3 transport/5xx, Redis blips, 7 * clamd timeouts. Thrown back to BullMQ → exponential backoff (2 s base, 8 * 3 attempts per the job tuning in §7). 9 * 10 * - FATAL — permanent: corrupt/undecodable object, unsupported format, 11 * MIME mismatch, missing row, disk guard. The File row is marked FAILED 12 * with `metadata.pipeline.error` and the job is DONE (no retries — 13 * retrying a corrupt object will never fix it). 14 * 15 * The classification is a pure function so it is unit-testable (DoD: the 16 * retryable-vs-fatal matrix). 17 */ 18import { S3Error } from "../../lib/s3.js"; 19 20/** Thrown for RETRYABLE failures — BullMQ catches and backs off. */ 21export class RetryableError extends Error { 22 /** The underlying transport error (for logging). */ 23 readonly underlying?: unknown; 24 constructor(message: string, underlying?: unknown) { 25 super(message); 26 this.name = "RetryableError"; 27 this.underlying = underlying; 28 } 29} 30 31/** 32 * Thrown for FATAL failures. The processor catches this, writes the File row 33 * (status FAILED + `metadata.pipeline.error`) and completes the job without 34 * retry. 35 */ 36export class FatalError extends Error { 37 constructor( 38 message: string, 39 /** Short stable code, e.g. "corrupt_object", "mime_mismatch", "disk_full". */ 40 public readonly code: string 41 ) { 42 super(message); 43 this.name = "FatalError"; 44 } 45} 46 47/** Wrap an unknown thrown value in the appropriate pipeline error class. */ 48export function classifyError(err: unknown): RetryableError | FatalError { 49 if (err instanceof RetryableError) return err; 50 if (err instanceof FatalError) return err; 51 52 const msg = err instanceof Error ? err.message : String(err); 53 54 // S3 transport errors (the worker's own client throws S3Error with a 55 // statusCode; network-level failures surface as fetch TypeError 56 // "fetch failed" / cause codes). 57 if (err instanceof S3Error) { 58 if (err.statusCode === 404) { 59 // Object gone (never landed, or deleted): not retryable, not "corrupt" 60 // — the caller decides (missing at HEAD = fatal "upload_not_found"). 61 return new FatalError(msg, "upload_not_found"); 62 } 63 if (err.statusCode >= 500) return new RetryableError(msg, err); 64 // 400/401/403: bad request or auth — retrying won't help. 65 return new FatalError(msg, "s3_client_error"); 66 } 67 68 // Network / socket failures. 69 if (err instanceof TypeError && /fetch failed|network|socket/i.test(msg)) { 70 return new RetryableError(msg, err); 71 } 72 if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE|UND_ERR_SOCKET|EAI_AGAIN/i.test(msg)) { 73 return new RetryableError(msg, err); 74 } 75 76 // Redis blips (ioredis "Connection is closed." etc). 77 if (/redis|Connection is closed|Ready check failed/i.test(msg) && err instanceof Error) { 78 return new RetryableError(msg, err); 79 } 80 81 // Everything else is treated as FATAL: retrying an unknown, deterministic 82 // failure (corrupt object, parser throw, bad input) wastes the attempt 83 // budget. Callers that know a failure is transient should throw 84 // RetryableError explicitly. 85 return new FatalError(msg, "unclassified"); 86} 87