Skip to content

fix(client): keep the HTTP status and describe the failure on DuffelError - #1192

Open
todor-a wants to merge 1 commit into
duffelhq:mainfrom
todor-a:fix-client-error-details
Open

fix(client): keep the HTTP status and describe the failure on DuffelError#1192
todor-a wants to merge 1 commit into
duffelhq:mainfrom
todor-a:fix-client-error-details

Conversation

@todor-a

@todor-a todor-a commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

When the API responds with something other than JSON — a gateway or proxy answering instead of the API — the resulting DuffelError carries no status, no message and no error details. There is nothing for a caller to branch on and nothing useful to log.

We hit this in production and could not tell from the error whether to retry, back off, or treat it as a bad request. Tracing it back gave three causes, all in Client.ts.

Observed

When 2026-07-24T14:01:34.278Z
Endpoint POST air/offer_requests
SDK @duffel/api 4.28.0, Node
x-request-id absent

The missing x-request-id is the useful part: the response never reached a point where one was issued, which points at the edge rather than the API — and is also why we cannot give you an identifier to trace. Recovering the status is the only thing that would have let us classify it.

This is the occurrence we captured end to end. We have seen the same signature since, but this is the one we can evidence precisely.

1. The status is passed to the constructor and then dropped

throw new DuffelError({
  ...responseBody,
  status: response.status,   // passed in…
  headers: response.headers,
})
constructor({ meta, errors, headers }) {   // …never read
  super()
  this.meta = meta
  this.errors = errors
  this.headers = headers
}

meta.status covers this whenever the API returns JSON, so it usually goes unnoticed. But responseBody is only an object in the JSON branch:

if (contentType && contentType.includes('json')) {
  responseBody = await response.json()
} else {
  responseBody = (await response.text()) as any
}

Otherwise it is a string, and spreading a string yields index keys — so meta and errors both come out undefined, and the status the constructor was handed is lost.

That is exactly the gateway-error case: a 502 or 503 HTML page from something sitting in front of the API. It is where a caller most wants the status code, and the only place it is unavailable.

2. Every DuffelError has an empty message

super() is called with no argument, so message is '' on every instance — including well-formed API errors that already carry a title and message in errors[0]. Anything reporting err.message shows nothing.

3. name is never set

DuffelError extends Error without setting this.name, so err.name is 'Error'. After bundling the class name is minified too, so there is no reliable way to identify the error type from a serialised log.

The change

  • Add status to DuffelError, from the value the constructor already receives, falling back to meta?.status.
  • Derive message from the first API error (title: message), or from the status when there is no error payload — Duffel responded with HTTP 503 and no error details.
  • Set this.name = 'DuffelError'.

meta, errors and headers are untouched and status is additive, so existing consumers are unaffected. The observable difference is that message and name now carry content where they previously carried none.

Tests

Adds src/Client.spec.ts — there wasn't one — covering a JSON 404 with an error payload and a 503 with an HTML body. Both fail on main and pass with this change.

Full suite: 141 passed across 38 suites, no regressions. tsc --noEmit, ESLint and Prettier clean.

@todor-a
todor-a requested a review from a team as a code owner July 26, 2026 18:59

@igorp1 igorp1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Todor, thanks for putting this together. I agree the current behaviour is pretty opaque, and keeping the HTTP status the SDK actually received would be useful.

One thing we need to be careful about, though, is what that status represents: when the response comes from a proxy or gateway rather than Duffel, it doesn’t necessarily tell you the final state of the request. A gateway could time out while Duffel still completes the request successfully, so consumers shouldn’t treat that status as the authoritative Duffel outcome or use it alone to decide whether to retry.

I’m also a little concerned about changing message and name here, since those are observable runtime changes and could break consumers relying on existing logging, error grouping, tests, or serialization.

Would you be open to narrowing this down to surfacing the received HTTP status, with the distinction above made clear?

Client.request builds errors as new DuffelError({ ...responseBody, status, headers }), but the
constructor only reads meta, errors and headers, so the status it is handed is dropped. meta is
populated only when the API returns a JSON body, and responseBody is a string otherwise, so
spreading it yields index keys and leaves meta undefined. Whenever a proxy or gateway answers on
Duffel's behalf the status is therefore unrecoverable by the caller.

Store the status on DuffelError, falling back to meta.status. It records the response that arrived,
which is not necessarily the outcome of the request at Duffel, so it is documented as diagnostic
information rather than a retry signal.

meta, errors, headers, message and name are unchanged, so this is additive for existing consumers.
@todor-a
todor-a force-pushed the fix-client-error-details branch from 77bb19a to e870965 Compare July 28, 2026 06:10
@todor-a

todor-a commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Hey, Igor. Thank you for taking the time to take a look at this.

I agree, the initial diff was a bit too aggressive. I've slimmed it down.

@igorp1 igorp1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for considering my input!

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.

2 participants