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

Source: https://voxgig.com/howto/map-vendor-errors-onto-message-errors

- Audience: platform-team
- Level: intermediate
- Languages: typescript
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://docs.nestjs.com/microservices/exception-filters) | 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](https://github.com/senecajs/seneca-provider) | 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](https://senecajs.org/api/) | 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.

```ts title="outcomes.ts"
// 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`](https://www.rfc-editor.org/rfc/rfc9110.html#name-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](https://docs.stripe.com/api/errors). Globex
puts a boolean `ok` in every response body, a convention
[Slack's Web API](https://docs.slack.dev/apis/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.

```ts title="classify.ts"
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](https://senecajs.org/docs/tutorials/logging-with-seneca.html).

```ts title="provider.ts"
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.

```bash
node log.ts
```

```text output
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.

```bash
node compare.ts
```

```text output
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.

```bash
node nest.ts
```

```text output
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](https://docs.nestjs.com/microservices/exception-filters) 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.

```bash
node --test providers.test.ts
```

```text output
# 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](https://github.com/senecajs/seneca/blob/v3.38.0/seneca.ts) applied. Pass
[`AbortSignal.timeout`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static)
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

- [Translate upstream API errors behind your own API](/howto/translate-upstream-errors-behind-your-api)

- [Evolve an error contract without breaking clients](/howto/evolve-an-error-contract-without-breaking-clients)

- [Migrate a hand-written vendor client to a provider plugin](/howto/migrate-a-vendor-client-to-a-provider)

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

[^1]: The two shapes have a standard between them that neither uses.
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) 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.

[^2]: The [Fetch Standard](https://fetch.spec.whatwg.org/#port-blocking) 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.