Skip to content

docs(openapi): declare the statuses routes can already answer - #1167

Merged
rmyndharis merged 1 commit into
mainfrom
docs/declare-reachable-statuses
Aug 9, 2026
Merged

docs(openapi): declare the statuses routes can already answer#1167
rmyndharis merged 1 commit into
mainfrom
docs/declare-reachable-statuses

Conversation

@rmyndharis

Copy link
Copy Markdown
Owner

What

The contract described the happy path and the timeout but almost nothing in between. This adds 125 declarations across eleven controllers. No runtime behaviour changes — every status added was already reachable before this PR.

Status Routes Error class
409 91 EngineNotReadyError
501 11 EngineNotSupportedError, ChannelMediaNotSupportedError
404 11 Message / Group / Channel / LabelNotFoundError
400 6 RecipientUnreachableError
403 6 EngineRefusedError

Follows #1163, which did the same for 503 alone. The generalisation is that thirteen error classes in src/common/errors map to a status through their NestJS base, not one.

The 409, which is most of it

Every adapter method calls ensureReady() before touching the socket or the page — 168 call sites — and that guard throws EngineNotReadyError. So any route that reaches an engine answers 409 when the session exists but has no live engine behind it. None of the 91 said so.

Ten routes already declared a 409, for an unrelated reason: a name already taken, a plugin already installed, a session already running. Those are semantic conflicts rather than a missing engine, and the two sets do not overlap, so the new declaration carries its own description distinguishing them.

How the route set was derived

Handler → service → both adapters, per route. Two things are worth stating because they changed the answer:

Reachability is type-resolved, not name-matched. An earlier pass keyed on the method name alone and wrongly flagged GET /plugins/catalog: PluginsService.getCatalog shares a name with CatalogService.getCatalog, and only the latter touches an engine. Resolving this.<prop>.<method>() through the controller's constructor types drops it, and re-running the corrected predicate reports zero remaining mismatches.

The predicate was checked against a known answer. Of the 52 routes declaring 503, it flags 49 as engine-reaching and excludes exactly the three whose 503 comes from elsewhere — the readiness probe and the two media-conversion routes. That is the expected split, which is what makes the 91 trustworthy.

"Session-scoped" was deliberately not used as the criterion, though it was how the scale was first measured: GET /sessions/{id}/config sits under that prefix and never reaches an engine, so keying on the path shape would have published a status that cannot occur.

Two structural choices

Descriptions live in one shared module (src/common/openapi/engine-status-responses.ts) rather than per controller. The text describes engine behaviour, not any one route's, so eleven copies would drift the moment one was reworded — which is how the contract fell behind in the first place.

They are applied per handler, not at class level. Seven of the eleven controllers are 100% engine-touching, so a class-level @ApiResponse would have cut the diff from 92 lines to 7. It was rejected: a future route added to one of those controllers would silently inherit a 409 whether or not it touches an engine, which is the same rot as a hand-maintained list, only harder to notice.

The one that mattered most

On the four group participant writes, the undeclared 403 is the status separating a batch WhatsApp refused outright from one that was applied with some members rejected — the latter is reported inside results on a 200. Those four now declare 200, 403, 409, 503.

Verification

openapi:check, lint, format:check, tsc --noEmit, build all exit 0; npm test passes 5119 tests across 300 suites. openapi.json regenerated with npm run openapi:export, never hand-edited. Status totals moved exactly as intended: 400 64→70, 403 21→27, 404 64→75, 409 10→101, 501 16→27, with 503 unchanged at 52.

The contract described the happy path and the timeout but almost nothing in
between. 125 declarations are added across eleven controllers; no runtime
behaviour changes, and every status added was already reachable before this.

The set is derived, not listed. Thirteen error classes in src/common/errors map
to a status through their NestJS base, and each route was traced from handler to
service to both adapters to see which of them it can reach:

  409  EngineNotReadyError            91 routes
  501  EngineNotSupported / ChannelMediaNotSupported  11
  404  Message/Group/Channel/LabelNotFound            11
  400  RecipientUnreachableError                       6
  403  EngineRefusedError                              6

409 is the bulk of it and was the least visible. Every adapter method calls
ensureReady() before touching the socket or the page — 168 call sites — and that
guard throws EngineNotReadyError, so any route reaching an engine answers 409
when the session has no live engine behind it. Ten routes declared a 409 already,
but for an unrelated reason: a name already taken, a plugin already installed, a
session already running. Those are semantic conflicts, not a missing engine, and
the two sets do not overlap, so the new declaration carries its own description
saying which of the two it is.

Reachability was type-resolved, not name-matched. An earlier pass keyed on the
method name alone and wrongly flagged GET /plugins/catalog, because
PluginsService.getCatalog shares a name with CatalogService.getCatalog and only
the latter touches an engine. Resolving `this.<prop>.<method>()` through the
controller's constructor types removes it; re-running the corrected predicate
reports zero remaining mismatches. The predicate was also checked against a known
answer: of the 52 routes declaring 503, it flags 49 as engine-reaching and
excludes exactly the three whose 503 comes from somewhere else — the readiness
probe and the two media-conversion routes.

Descriptions live in one shared module rather than per controller. The text
describes engine behaviour, not any one route's, so eleven copies would drift the
moment one was reworded — which is how the contract fell behind in the first
place. They are still applied per handler rather than at class level: a
class-level @apiresponse would silently hand a 409 to any future route added to
that controller, engine-touching or not.

On the four participant writes the undeclared 403 mattered most: it is the status
that separates a batch WhatsApp refused outright from one that was applied with
some members rejected, which a 200 reports inside `results`.
@rmyndharis
rmyndharis merged commit ab29405 into main Aug 9, 2026
16 checks passed
@rmyndharis
rmyndharis deleted the docs/declare-reachable-statuses branch August 9, 2026 03:00
pull Bot pushed a commit to felipefarinha/OpenWA that referenced this pull request Aug 9, 2026
…e's work

A self-review of the ten PRs merged this cycle found four things wrong, three of
them introduced by rmyndharis#1167 itself.

The 409 description was the worst of them. It read "it exists, but no engine is
running for it", which describes the case that actually answers 400: the service
resolves the engine first and throws BadRequestException('Session is not
started') when there is none. ensureReady() fires only when an engine EXISTS and
is not READY — disconnected, reconnecting, or still initializing, as its own
comment and EngineNotReadyError's doc both say. Confirmed behaviourally against a
running instance: three engine routes on a never-started session all answered
400 "Session is not started". The text was published on 91 operations, and the
docs site already described the split correctly, so the two disagreed.

send-bulk declared a 409 it cannot produce. createBatch's engine check throws a
400, and the batch itself drains through processBatch, which is started
fire-and-forget after the handler has already answered 202 — nothing an adapter
throws can reach that response. An engine that goes unready mid-batch shows up in
the per-message results on GET /messages/batch/{batchId} instead.

The nine catalog and status routes gained that 409 while their most likely error
stayed undeclared. Both services pass a NotFoundException factory to
EngineRegistry.require() rather than taking its BadRequestException default, so
an unstarted session is a 404 there. Declaring the rare status and omitting the
common one is worse than declaring neither; the 404 is now documented, with
wording that flags the divergence from the message and group modules. The
divergence itself is left alone — changing it would change behaviour.

The logout example showed `pushName: null` and `connectedAt: null` beside a
populated `lastActive`. Logout clears `phone` only, so a session that had ever
connected keeps both. rmyndharis#1166 made that example schema-valid by adding the two
required fields but did not check the ones already there.

Contract totals move as expected: 409 101 -> 100, 404 75 -> 84.
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