Skip to content

refactor(workflow-executor): centralize HTTP error mapping into typed errors [PRD-477]#1657

Open
Scra3 wants to merge 6 commits into
mainfrom
feature/prd-477-refactor-centralize-executor-http-error-mapping-typed-http
Open

refactor(workflow-executor): centralize HTTP error mapping into typed errors [PRD-477]#1657
Scra3 wants to merge 6 commits into
mainfrom
feature/prd-477-refactor-centralize-executor-http-error-mapping-typed-http

Conversation

@Scra3

@Scra3 Scra3 commented Jun 11, 2026

Copy link
Copy Markdown
Member

What

Centralizes the executor's error → HTTP-response mapping (PRD-477). Previously, translation was scattered across three places in executor-http-server.ts (error middleware, hasRunAccessMiddleware, handleTrigger with one instanceof branch per error type), and was already inconsistent — an orchestrator failure surfaced as 400 on POST /trigger but 500 on GET /runs.

Design

Domain error categories (transport-agnostic). A small set of abstract categories under WorkflowExecutorError in errors.ts:

Category Meaning HTTP
NotFoundError the requested entity doesn't exist 404
AccessDeniedError actor not permitted 403
UnavailableError internal dependency down, retryable 503
(uncategorized WorkflowExecutorError) known domain failure 400

Run lifecycle errors extend their category: RunNotFoundError → NotFoundError, UserMismatchError → AccessDeniedError, RunStorePortError/WorkflowPortError → UnavailableError. RunAlreadyInFlightError stays a 400 (an explicit branch flags it as expected churn so it isn't logged).

Single translator. toHttpError(err) maps by category, not by concrete class — so a new request-level error (e.g. StepNotFoundError extends NotFoundError) gets the right status with zero change to the HTTP layer. Returns null for untranslatable errors → middleware responds a generic 500 (no internals leaked).

Error-translation middleware owns all mapping; handlers just throw. The HTTP side is one concrete class per status (BadRequestHttpError, UnauthorizedHttpError, ForbiddenHttpError, NotFoundHttpError, ServiceUnavailableHttpError) — no per-error leaf classes.

Logging. A log flag on each HTTP error: genuine server/security faults (503, user mismatch) are logged with the underlying cause's stack; expected client churn (404/400) stays out of the logs. UserMismatchError carries the bearer/owner ids in its technical message (logged) but never in the body — 403 responses stay opaque (Forbidden).

⚠️ Behaviour change (intentional)

Infra failures (WorkflowPortError / RunStorePortError) → 503 everywhere (were 400 on trigger, 500 on GET). Semantically correct (server fault, retryable). Every other response is byte-for-byte identical.

Tests

  • test/http/http-errors.test.ts: category mapping (404/403/503, the 400 default for an uncategorized WorkflowExecutorError incl. RunAlreadyInFlightError, null → 500), status-class invariants, koa-jwt 401.
  • test/http/executor-http-server.test.ts: end-to-end statuses incl. the new 503, middleware logs log:true errors (asserting the stack comes from the cause) and stays quiet for expected churn.
  • Full suite: 1018 passing; the existing http-server test lines pass unchanged (non-regression).

Refs PRD-477

Note

Centralize HTTP error mapping in workflow-executor using typed domain errors

  • Introduces typed base error classes (NotFoundError, AccessDeniedError, UnavailableError) in errors.ts and re-parents existing domain errors under them.
  • Adds http-errors.ts with concrete HTTP error classes (400/401/403/404/503) and a toHttpError utility that maps domain errors to HTTP responses.
  • Refactors executor-http-server.ts to bubble domain errors to a single middleware that calls toHttpError, replacing per-handler try/catch cascades.
  • RunStorePortError and WorkflowPortError now map to 503; UserMismatchError to logged 403; RunAlreadyInFlightError to non-logged 400; unmapped errors still return 500.
  • Behavioral Change: access-check failures now return 503 with a stable body; forbidden access returns 403; invalid bearer user ID returns 400; only selected errors are logged with cause stacks.

Macroscope summarized 3f17687.

… errors [PRD-477]

Introduce a BaseHttpError > status class > precise-case hierarchy and a single
toHttpError translator. The error-translation middleware now owns all error->HTTP
mapping; handlers and hasRunAccessMiddleware just throw typed errors.

Behaviour change: infra failures (WorkflowPortError/RunStorePortError) now map to
503 with their userMessage everywhere (previously 400 on trigger, 500 on GET).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jun 11, 2026

Copy link
Copy Markdown

PRD-477

@qltysh

qltysh Bot commented Jun 11, 2026

Copy link
Copy Markdown

1 new issue

Tool Category Rule Count
qlty Structure Function with many returns (count = 8): toHttpError 1

@qltysh

qltysh Bot commented Jun 11, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (4)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/runner.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/http/executor-http-server.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/errors.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/http/http-errors.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

alban bertolini and others added 5 commits June 11, 2026 15:05
…d lock stack-from-cause [PRD-477]

Post-review fixes for the centralized HTTP error mapping:
- UserMismatchError now carries bearerUserId + ownerUserId in its message, so the
  centralized request log keeps the authz forensic signal (who tried to act on a
  run they don't own) that the per-handler log used to provide. Ids stay in the
  log only — the HTTP body remains 'Forbidden', no id leak.
- Middleware falls back to the HTTP error's own stack when the cause has none, and
  a test now asserts the logged stack comes from the cause (the real fault site),
  not the synthetic wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… HTTP errors [PRD-477]

abstract already prevents direct instantiation of the status classes, so the
protected modifier only forced no-arg leaves to re-declare a public constructor.
Removing it lets MissingOrInvalidTokenHttpError/RunAccessDeniedHttpError be empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…in family [PRD-477]

RunNotFoundError / UserMismatchError / RunAlreadyInFlightError now extend
WorkflowExecutorError instead of plain Error, so every domain error shares one
base. toHttpError keeps an explicit branch per status (404/403/400), but a
forgotten mapping now degrades to a 400 with the userMessage instead of a silent
500. UserMismatchError keeps the bearer/owner ids in its technical message only;
its userMessage stays id-free so the HTTP body never leaks ownership.

No behaviour change: these are thrown solely at the Runner.triggerPoll level
(verified), never inside an instanceof-WorkflowExecutorError guard scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… per-class [PRD-477]

Introduce transport-agnostic domain categories (NotFoundError, ConflictError,
AccessDeniedError, UnavailableError) under WorkflowExecutorError. toHttpError now
maps by category, so a new request-level error gets its status by extending the
right category — no HTTP binding to add. The HTTP side collapses to one concrete
class per status (no per-error leaf classes); handlers throw them directly.

Behaviour change: RunAlreadyInFlightError now maps to 409 Conflict (was 400),
the semantically correct status for "run already being processed". 403 bodies
stay opaque ('Forbidden').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sed Conflict category [PRD-477]

Revert the 409 change: RunAlreadyInFlightError stays a 400. It's uncategorized
(an explicit toHttpError branch flags it as expected churn → not logged), so the
ConflictError category and ConflictHttpError, now memberless, are removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant