From e8709651e7b8ab1a0a08eb186a3b460942b1ee9f Mon Sep 17 00:00:00 2001 From: Todor Andonov Date: Sun, 26 Jul 2026 21:50:36 +0300 Subject: [PATCH] fix(client): keep the HTTP status the SDK received on DuffelError 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. --- src/Client.spec.ts | 51 ++++++++++++++++++++++++++++++++++++++++++++++ src/Client.ts | 17 ++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 src/Client.spec.ts diff --git a/src/Client.spec.ts b/src/Client.spec.ts new file mode 100644 index 00000000..62d6f949 --- /dev/null +++ b/src/Client.spec.ts @@ -0,0 +1,51 @@ +import nock from 'nock' +import { Client } from './Client' + +const client = new Client({ token: 'mockToken' }) + +describe('Client', () => { + afterEach(() => { + nock.cleanAll() + }) + + test('should keep the status of a JSON error response', async () => { + nock(/(.*)/) + .get('/air/offers/off_123') + .reply(404, { + meta: { request_id: 'req_123', status: 404 }, + errors: [ + { + code: 'not_found', + title: 'Resource not found', + message: 'The resource you requested could not be found.', + type: 'invalid_request_error', + documentation_url: '', + }, + ], + }) + + await expect( + client.request({ method: 'GET', path: '/air/offers/off_123' }), + ).rejects.toMatchObject({ + status: 404, + meta: { request_id: 'req_123', status: 404 }, + }) + }) + + test('should keep the status when the error response is not JSON', async () => { + nock(/(.*)/) + .get('/air/offer_requests') + .reply(503, '503 Service Unavailable', { + 'content-type': 'text/html', + }) + + // A gateway responding on Duffel's behalf produces no `meta`, so `status` is the only + // indication of what went wrong. + await expect( + client.request({ method: 'GET', path: '/air/offer_requests' }), + ).rejects.toMatchObject({ + status: 503, + meta: undefined, + }) + }) +}) diff --git a/src/Client.ts b/src/Client.ts index a33b236a..58bfa05c 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -20,19 +20,36 @@ export class DuffelError extends Error { public errors: ApiResponseError[] public headers: Headers + /** + * The [HTTP status](https://httpstatuses.com/) the SDK received. + * + * `meta` is only populated when the API returned a JSON body, so this is the + * only status available when a proxy or gateway responds with something else. + * + * It is the status of the response that arrived, not necessarily the outcome + * of the request at Duffel: a gateway can time out or fail while Duffel still + * processes the request successfully. Treat it as diagnostic information, and + * do not use it on its own to decide whether the request took effect or + * whether it is safe to retry. + */ + public status: number | undefined + constructor({ meta, errors, headers, + status, }: { meta: ApiResponseMeta errors: ApiResponseError[] headers: Headers + status?: number }) { super() this.meta = meta this.errors = errors this.headers = headers + this.status = status ?? meta?.status } }