Your order service catches Error: Provider acme 404 from the tracking provider and parses the
status out of the message. The second tracking vendor never throws: it answers HTTP 200 with
"ok": false in the body, which your caller treats as a parcel. Every caller carries one error
path per vendor, and adding a third vendor means editing all of them.
What you get
You will end up with a provider per vendor that answers every failure with one of five coded outcomes, and one caller for both. A log line keeps each vendor’s own code. This is for you if callers branch on a vendor’s HTTP status.
Short answer
Define the five outcomes your callers branch on: not_found, rejected, rate_limited,
unavailable and credentials. Give each vendor one table inside its provider that maps every
status and error code onto one of them. Reply with ok: false, why, retryable and a detail
field holding the vendor’s status, code, and body. Log the vendor’s code on the same line as the
outcome.
You will need
Node 22.18 or later, which runs the TypeScript samples without a build step, a provider plugin per
vendor, and each vendor’s list of statuses and error codes. Writing the provider itself is covered
by the page on wrapping a third-party API as a provider plugin, which is not published yet. The
passthrough comparison uses @seneca/provider 4.0.0, which declares Node 24 or later; on Node 22
npm warns at install and the calls used here run.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| NestJS typed exceptions from a provider service | The provider is a NestJS service called in the same process, where instanceof works | Across a transport the class is gone, and an unexpected error arrives as Internal server error | Callers sit in another process and need the outcome as data |
| Passing the vendor error through untouched | One vendor and one caller, written by the same team, during a spike | Every caller learns each vendor’s statuses and envelopes, and the thrown error, which Seneca logs, carries your key | A second caller or a second vendor exists |
| Seneca provider coded error replies | Callers in any process branch on a small set of outcomes, whichever vendor answered | A mapping table per vendor to keep current, and one more place a vendor code can hide | You have one vendor and no plan for a second |
Voxgig maintains Seneca and @seneca/provider. This page compares a coded reply from a Seneca provider with NestJS typed exceptions and with passing the vendor’s error through.
A typed exception is the most natural shape inside one NestJS process, but the type is lost the moment the call crosses a transport. Passthrough costs nothing to write, but every caller pays for it with a copy of every vendor’s quirks. A coded reply survives any transport as plain data, at the price of a table you maintain per vendor.
Pick the outcomes callers branch on
Start from what a caller does next, not from what vendors send. A caller can do five things. It tells its own client the thing does not exist, or that its input was refused. It waits and retries, retries later or fails over, or wakes someone because the credentials are wrong.
// The five outcomes every caller branches on, whichever vendor is behind the provider.
export type Outcome = 'not_found' | 'rejected' | 'rate_limited' | 'unavailable' | 'credentials'
export type Detail = { vendor: string; status: number; code: string; body: string }
export type Failure = { ok: false; why: Outcome; retryable: boolean; retry_after_s?: number; detail: Detail }
retryable is part of the reply because the caller should not decide it from why alone. A
rate_limited reply also carries retry_after_s, read from the vendor’s
Retry-After header.
Map each vendor in one table
The two vendors here fail in the two shapes vendors use. Acme answers with HTTP statuses and a
typed error object, the shape of Stripe’s errors. Globex
puts a boolean ok in every response body, a convention
Slack’s Web API also uses, and it answers most failures
with HTTP 200.1 Each gets a classifier that turns a status and
a body into an outcome and the vendor’s code.
export function classifyGlobex(status: number, body: any): Classified {
if (status === 429) return { ok: false, why: 'rate_limited', code: String(body?.error ?? 'http_429') }
if (status >= 500) return { ok: false, why: 'unavailable', code: `http_${status}` }
if (typeof body !== 'object' || body === null) return { ok: false, why: 'unavailable', code: 'not_json' }
if (body.ok === true) return { ok: true, value: body.parcel }
const code = String(body.error ?? 'unknown')
const why = GLOBEX_CODE[code]
return why ? { ok: false, why, code } : { ok: false, why: 'rejected', code, unmapped: true }
}
A code missing from the table still gets an answer, rejected, and the flag unmapped. A body
that is not JSON, such as a maintenance page served with status 200, is unavailable rather than
a parse error thrown at the caller.
Reply with the outcome and log the original
The provider answers with the outcome and keeps the vendor’s status, code, and the first 2048
characters of the body in detail. It writes the vendor’s code and the outcome to the log on one
line, through Seneca’s own logger.
function fail(seneca: any, vendor: string, result: Classified & { ok: false }, status: number, text: string, retry_after_s?: number): Failure {
const { why, code, unmapped } = result
// One line with both codes: the stable one to count, the vendor's to debug with.
seneca.log.warn({ vendor_error: { vendor, why, status, code, ...(unmapped ? { unmapped } : {}) } })
return {
ok: false,
why,
retryable: RETRYABLE[why as Outcome],
...(retry_after_s === undefined ? {} : { retry_after_s }),
detail: { vendor, status, code, body: text.slice(0, BODY_LIMIT) },
}
}
That log line is the answer to the trade-off. A normalized error lets two vendors share one caller, but it hides the detail you need on the day only Acme fails, so the log line keeps that detail.
node log.ts
warn vendor:acme why:unavailable status:503 code:api_error
warn vendor:globex why:unavailable status:502 code:http_502
warn vendor:acme why:rejected status:409 code:idempotency_error unmapped
warn vendor:globex why:rejected status:200 code:account_inactive unmapped
The provider takes its key as a function rather than a string. At debug level, Seneca logs each plugin’s options when it loads, and a function serializes as nothing.
Compare the three on the same eight answers
compare.ts sends the same eight vendor answers through a passthrough provider, built on
@seneca/provider’s getJSON, and through the mapped one.
node compare.ts
not_found
acme passthrough: threw "Provider acme 404", key in the error mapped: not_found
globex passthrough: returned {"ok":false,"error":"parcel_not_found"} mapped: not_found
bad_input
acme passthrough: threw "Provider acme 400", key in the error mapped: rejected
globex passthrough: returned {"ok":false,"error":"invalid_arguments"} mapped: rejected
rate_limited
acme passthrough: threw "Provider acme 429", key in the error mapped: rate_limited, retry in 7s
globex passthrough: threw "Provider globex 429", key in the error mapped: rate_limited, retry in 30s
outage
acme passthrough: threw "Provider acme 503", key in the error mapped: unavailable
globex passthrough: threw "Provider globex 502", key in the error mapped: unavailable
bad_key
acme passthrough: threw "Provider acme 401", key in the error mapped: credentials
globex passthrough: returned {"ok":false,"error":"invalid_auth"} mapped: credentials
Passthrough gives one caller two shapes per failure: a thrown error from Acme, a successful reply
from Globex. The thrown error also carries provider.options, the plugin’s options, which include
every key it holds. The caller does not have to log it for the key to leak. At Seneca’s default
level, info, the error line Seneca 3.38.0 writes for the failed action carries the same options,
and the test suite asserts that line holds the key. The mapped column is the same for both vendors
on every row.
The NestJS version throws a VendorError class per failure, and the caller sits across the TCP
transport.
node nest.ts
not_found caught {"why":"not_found","vendor":"acme","status":404,"code":"resource_missing"}
instanceof VendorError: false, instanceof Error: false
rate_limited caught {"why":"rate_limited","vendor":"acme","status":429,"code":"rate_limit_error"}
instanceof VendorError: false, instanceof Error: false
bug caught {"status":"error","message":"Internal server error"}
instanceof VendorError: false, instanceof Error: false
The payload arrives intact as the object passed to RpcException, which the
NestJS documentation says it returns as
is. The class does not arrive, so instanceof fails and a caller is back to
branching on a field, the same as the Seneca reply. A plain TypeError loses everything.
Check it worked
Six tests pin it: every scenario maps to the same outcome for both vendors, and one caller returns
the same HTTP status to its own client for both. The others check the log line and the
unmapped flag, and that the key is in no reply or log line. They cover the timeout, a refused
connection, a body that stalls halfway, and a body over the limit. An id like .. is refused
before any call.
node --test providers.test.ts
# tests 6
# suites 0
# pass 6
# fail 0
When it goes wrong
A caller treats a failure as a parcel. The vendor answers HTTP 200 with an error in the body and
the classifier only read the status. Check the envelope’s own success flag before anything else,
as classifyGlobex does.
A slow vendor holds a caller for twenty-two seconds. The provider had no timeout, so Seneca’s
default of 22222 milliseconds applied. Pass
AbortSignal.timeout
to fetch, and read the body inside the same try, since the signal also covers a body that
stops arriving.
A test meant to simulate a refused connection reports bad port. Fetch refuses some ports before
connecting, so point the test at a port that was listening a moment ago.2
When not to do this
Do not hide the vendor’s code from your own logs. The outcome is for callers, and the code is for whoever is on call. Without the second, an Acme outage and a Globex outage read as the same line.
Do not add outcomes for one caller’s convenience. Five that every caller handles beat nine that each caller handles half of, and a sixth belongs in the set only when a caller does something different with it.
Do not reach for Seneca to get coded replies. The reply shape is a convention, and it works from a NestJS handler that returns data instead of throwing. Seneca helps when callers already send messages, and a provider can move between a process and a transport without changing its replies.
Do not use the errors from getJSON in @seneca/provider 4.0.0 as your contract. They carry the
vendor’s status in the message text and in a Response object that no transport carries. They
also carry the plugin’s options, keys included, into Seneca’s error log.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0, seneca 3.38.0, seneca-promisify 3.7.2, seneca-entity
28.1.0, @seneca/provider 4.0.0 and @nestjs/microservices 12.1.3 with @nestjs/core and
@nestjs/common 12.1.3. Every output block is what the command preceding it printed, run in the
page’s code directory after npm ci. Both vendors are local servers imitating the two shapes, so
no request reached Stripe or Slack.
Footnotes
-
The two shapes have a standard between them that neither uses. RFC 9457 defines Problem Details for HTTP APIs, a JSON object with a type, a status, a title, a detail and an instance, served as
application/problem+json. It obsoleted RFC 7807, which had defined the same object in March 2016. A vendor that adopts it still needs a table on this page, because the standard says how to describe an error and leaves which error to the vendor. ↩︎ Back to text -
The Fetch Standard keeps a table of bad ports, and a request to one is a network error before any connection is attempted. Port 9, the discard service, is on it. The port a test reaches for when it wants nothing listening is therefore one that fetch declines to try. ↩︎ Back to text