Skip to content

Silent Failure Audit β€” 2026-09-07

Automated read-only audit. No source files were modified.

Scope

  • Changed files (last 7 days): 528 source files (.ts/.tsx/.py/.mjs, tests and .d.ts excluded), spanning apps/admin (287 β€” the new admin clone landed this week), workers/api (58), apps/web (46), apps/forms (42), services/flyer-tagger (25), plus tools/packages.
  • Random unchanged sample (10 files) from workers/api/src/routes/ and workers/api/src/utils/: account.ts, admin-pricing.ts, admin.ts, artifact-download.ts, benchmark.ts, client-logs.ts, download.ts, files.ts, jobs.ts, lti.ts.

Detection method: brace-matched catch-body extraction (Python AST-free scanner) flagging bodies with no log/console/throw/reject/user-visible-error call; plus greps for empty catches, console.error|warn, floating .then() without .catch(), and detached/void promises.

Summary

SeverityCount
Critical0
High2
Medium6
Low4
  • 230 catch blocks with no logging/rethrow were flagged mechanically; 89 of those are in apps/admin, which is a clone of apps/web β€” those duplicate the apps/web findings rather than adding new ones.
  • The overwhelming majority of flagged catches are legitimate, commented guards (JSON body β†’ 400, new URL() validation, localStorage blocked in private mode, LLM-JSON parse β†’ null with a caller-side check). Those are not reported below.
  • Only 2 empty catch {} exist in the changed set, both benign: apps/admin/public/pdf.worker.min.mjs (vendored pdf.js bundle) and workers/api/scripts/build-lambda.mjs:104 (build script).
  • The random unchanged sample was clean: no unlogged catch of a critical operation, no .then() without .catch().

Findings

HIGH-1 β€” Page-count failure silently truncates a Textract conversion to page 1

File: workers/api/src/services/textract-converter.ts:137-143

let pageCount = 1;
try {
pageCount = await countPdfPages(pdfData);
} catch {
pageCount = 1;
}

Why it matters: if countPdfPages throws on a 40-page document, the converter renders and bills page 1 only and returns a β€œsuccessful” conversion. Nothing is logged, no note is attached to the output, and the user has no signal that 39 pages are missing. The comment says most uncountable inputs are single-page β€” but the failure mode when that assumption is wrong is silent content loss in the deliverable.

Fix: log the throw at warn via getAppLogger() (workers/api/src/utils/app-logger) with filename and the error, and record a note on the conversion output (the pattern already used by textract-form-proposer.ts:481, which pushes 'page count fell open to 1' into notes). Consider failing the job outright when the PDF is larger than a size threshold that makes β€œit’s a one-page image” implausible.

HIGH-2 β€” Status polling swallows every error, leaving the UI spinning forever

File: apps/forms/src/app/dashboard/[jobId]/ConversionDetailClient.tsx:62-71

const interval = setInterval(async () => {
try {
const updated = await getConversionStatus(jobId, token);
...
} catch {
// ignore
}
}, 3000);

Why it matters: an expired token, a 5xx, or an API outage produces an unbounded silent retry loop. The user sees a progress indicator that never resolves and never errors β€” the exact failure class the project’s error-reporting work (useNotifyError / β€œwe’ve been notified”) was built to eliminate. It also has no consecutive-failure ceiling.

Fix: count consecutive failures; after N (3–5) stop the interval, surface a visible error state, and report via useNotifyError/reportClientError as apps/web’s TagEditor.tsx:147 already does.

MEDIUM-1 β€” Preview page-count fallback silently previews only page 1

File: workers/api/src/routes/convert.ts:5292-5297 β€” same catch { totalPages = 1 } shape as HIGH-1, feeding parsePageRanges/autoSamplePages. A parse failure collapses the preview sample to a single page with no log. Fix: log the error and return an explicit PDF_UNREADABLE error, or mark the preview as partial in the response.

MEDIUM-2 β€” AI cost estimate falls back to a magic number without logging

File: workers/api/src/routes/forms.ts:268-275

} catch {
// Non-fatal β€” use fallback estimate
estimatedAiCostUsd = pageCount * 0.05;
}

The global standard requires incremental-cost operations to record real cost. A silent $0.05/page guess pollutes cost reporting with no marker distinguishing estimated-from-fallback rows. Fix: log at warn, and persist a costEstimateSource: 'fallback' flag alongside the number.

MEDIUM-3 β€” Malformed AcroForm field tree silently reroutes a fillable PDF to the wrong lane

Files: workers/api/src/utils/pdf-utils.ts:48-55, apps/forms/src/lib/classify-pdf.ts:53-57

try { fieldCount = doc.getForm().getFields().length; } catch { fieldCount = 0; }

fieldCount === 0 classifies the document as flattened, sending a real fillable form down the scan/vision lane instead of the AcroForm lane. The parse failure β€” the only evidence that the classification is a guess rather than a reading β€” is discarded on both server and client. Fix: carry a fieldTreeUnreadable: true flag out of the classifier so the engine preflight and the UI can say β€œwe couldn’t read this form’s field tree” instead of quietly changing product behaviour.

MEDIUM-4 β€” Visual-layout scoring failure is discarded, silently degrading tier selection

File: workers/api/src/services/smart-cascade-converter.ts:1919-1922

} catch (_err) { /* visual layout scoring failed β€” ignore */ }

iterVl stays undefined, so the β€œaccepted at this tier” check can never pass and the page silently escalates to a more expensive tier (or lands on a worse result). A systematic scorer outage would show up only as an unexplained cost/quality shift. Fix: log at warn with page number and model, and count scorer failures in the job’s metrics.

MEDIUM-5 β€” Scheduled sweeps report failures only to console.error

File: workers/api/src/index.ts:478-510 (four sweeps: result-email, CRM reminders, issue reminders, team alerts)

Each sweep wraps its body in try/catch β†’ console.error. These are unattended cron paths whose entire purpose is notification; a persistently failing sweep produces no structured log line, no Loki label, and no alert. This is the same class of gap as the dead error-alerting pipeline found on 2026-09-04. Fix: route through the structured logger at error level so the existing Grafana β€œfatal/error rate” rules fire.

MEDIUM-6 β€” console.error/console.warn instead of the structured logger (systemic)

Confirmed occurrences in changed server-side code (non-exhaustive): workers/api/src/lambda-stream.ts:147,157,184,214; workers/api/src/index.ts:418,451,483,492,500,509; workers/api/src/routes/acr.ts:137,187,194,265,285,368,400,432,527,580,586; workers/api/src/routes/admin-crm.ts:90,113; workers/api/src/mcp/tools/acr.ts:129,136,220,237; workers/api/src/services/image-enhancer.ts:258,285; workers/api/src/routes/gateway.ts:109,278; workers/api/src/routes/convert.ts:5775; packages/shared/src/scan-metrics-writer.ts:36.

These lines are lost to log aggregation (no requestId, userId, level, or service label) and cannot trigger alerting. getAppLogger() from workers/api/src/utils/app-logger is already available and used by chunk-assembler.ts. Fix: mechanical replacement; consider an ESLint no-console rule for workers/**/src/** to stop the regression.

LOW-1 β€” Detached conversion promises on the Node path

workers/api/src/routes/gateway.ts:113 and :282. Both promises attach .catch() before being detached, so no unhandled rejection β€” but the bulk prewarm path only console.errors and never marks a job failed, so a prewarm that always fails is invisible. Add a structured log and a failure counter.

LOW-2 β€” Cache-invalidation deletes swallowed

workers/api/src/routes/files.ts:1189 and :1399 β€” catch (_err) { /* ignore if already gone */ } around storage.objects.delete(acrReportR2Key). β€œAlready gone” is fine; a genuine R2 outage is not, and would leave a stale ACR report served against edited HTML. Log at debug/warn rather than discarding.

LOW-3 β€” localStorage guards

apps/web/src/components/tag-editor/TagEditor.tsx:119,128 (and the apps/admin clone). Correct behaviour (private-mode fallback); the second one deserves the same explanatory comment as the first rather than /* ignore */.

LOW-4 β€” LLM-JSON parse fallbacks return null unlogged

alt-text-suggester.ts:119,190, verification-prograder.ts:119, acr-composer.ts:177, image-region-recovery.ts:129, chunk-assembler.ts:100. All callers null-check, so behaviour is safe; but a model that starts emitting unparseable JSON degrades output quality with zero telemetry. A single warn counter per parse helper would make that visible. Note image-enhancer.ts already does this well (four-stage recovery, parseMethod recorded, console.warn on total failure) β€” it is the pattern to copy, modulo MEDIUM-6.


Not findings (verified benign)

  • catch { return error(c, 'BAD_REQUEST', 'Request body must be JSON.', 400) } β€” ~25 occurrences across flyer.ts, forms-builder.ts, tagging-api.ts. Correct: the user gets a specific response.
  • catch { /* invalid origin URL β€” fall through */ } in index.ts, index-aws.ts, server.ts CORS handling.
  • workers/api/src/services/chunk-assembler.ts non-blocking recovery paths β€” every one logs warn with an errorId and component. Reference-quality handling.
  • workers/batch/src/*-executor.ts terminal catches all call fail(...), which persists job failure state.
  • apps/forms/src/components/review/FieldReviewCanvas.tsx:130 β€” sets a user-visible pageError.

Resource usage

  • Start: 2026-09-07T07:17:51Z
  • End: 2026-09-07T07:31:04Z
  • Duration: ~13m
  • Tool invocations: 16 (1 git log, 13 grep/scan/read Bash calls, 2 report/issue steps)
  • Files scanned: 538 (528 changed + 10 random unchanged)