/** * apps/worker — files pipeline error classification (TASK-0102 §1). * * Every step failure is classified as: * * - RETRYABLE — transient: network errors, S3 transport/5xx, Redis blips, * clamd timeouts. Thrown back to BullMQ → exponential backoff (2 s base, * 3 attempts per the job tuning in §7). * * - FATAL — permanent: corrupt/undecodable object, unsupported format, * MIME mismatch, missing row, disk guard. The File row is marked FAILED * with `metadata.pipeline.error` and the job is DONE (no retries — * retrying a corrupt object will never fix it). * * The classification is a pure function so it is unit-testable (DoD: the * retryable-vs-fatal matrix). */ import { S3Error } from "../../lib/s3.js"; /** Thrown for RETRYABLE failures — BullMQ catches and backs off. */ export class RetryableError extends Error { /** The underlying transport error (for logging). */ readonly underlying?: unknown; constructor(message: string, underlying?: unknown) { super(message); this.name = "RetryableError"; this.underlying = underlying; } } /** * Thrown for FATAL failures. The processor catches this, writes the File row * (status FAILED + `metadata.pipeline.error`) and completes the job without * retry. */ export class FatalError extends Error { constructor( message: string, /** Short stable code, e.g. "corrupt_object", "mime_mismatch", "disk_full". */ public readonly code: string ) { super(message); this.name = "FatalError"; } } /** Wrap an unknown thrown value in the appropriate pipeline error class. */ export function classifyError(err: unknown): RetryableError | FatalError { if (err instanceof RetryableError) return err; if (err instanceof FatalError) return err; const msg = err instanceof Error ? err.message : String(err); // S3 transport errors (the worker's own client throws S3Error with a // statusCode; network-level failures surface as fetch TypeError // "fetch failed" / cause codes). if (err instanceof S3Error) { if (err.statusCode === 404) { // Object gone (never landed, or deleted): not retryable, not "corrupt" // — the caller decides (missing at HEAD = fatal "upload_not_found"). return new FatalError(msg, "upload_not_found"); } if (err.statusCode >= 500) return new RetryableError(msg, err); // 400/401/403: bad request or auth — retrying won't help. return new FatalError(msg, "s3_client_error"); } // Network / socket failures. if (err instanceof TypeError && /fetch failed|network|socket/i.test(msg)) { return new RetryableError(msg, err); } if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE|UND_ERR_SOCKET|EAI_AGAIN/i.test(msg)) { return new RetryableError(msg, err); } // Redis blips (ioredis "Connection is closed." etc). if (/redis|Connection is closed|Ready check failed/i.test(msg) && err instanceof Error) { return new RetryableError(msg, err); } // Everything else is treated as FATAL: retrying an unknown, deterministic // failure (corrupt object, parser throw, bad input) wastes the attempt // budget. Callers that know a failure is transient should throw // RetryableError explicitly. return new FatalError(msg, "unclassified"); }