How-to › Run message-based services

How to map vendor error responses onto message errors#

Map every vendor status and error code onto five outcomes inside a Seneca provider, reply with a coded error, and log the vendor's own code beside it.

Audience
Platform team
Level
intermediate
Topic
Wrap external APIs as providers
Languages
TypeScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
NestJS typed exceptions from a provider serviceThe provider is a NestJS service called in the same process, where instanceof worksAcross a transport the class is gone, and an unexpected error arrives as Internal server errorCallers sit in another process and need the outcome as data
Passing the vendor error through untouchedOne vendor and one caller, written by the same team, during a spikeEvery caller learns each vendor’s statuses and envelopes, and the thrown error, which Seneca logs, carries your keyA second caller or a second vendor exists
Seneca provider coded error repliesCallers in any process branch on a small set of outcomes, whichever vendor answeredA mapping table per vendor to keep current, and one more place a vendor code can hideYou 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.

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

  1. 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

  2. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.