From 9ff24c7783f1487a6f36a0c461af6b69f9e375d7 Mon Sep 17 00:00:00 2001 From: Miki Palet <64255955+mikipalet@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:03:44 +0000 Subject: [PATCH] fix(errors): carry the whole API error envelope on ZernioApiError parseApiError built the error from body.error, body.code and body.details only, so of the seven canonical ErrorResponse fields it surfaced three. type, param, platform and platformError were dropped on EVERY endpoint. For Meta sends that is fatal to error handling: a closed messaging window and a blocked recipient both arrive as code platform_api_error, and only platformError.subcode separates them, which was the field being dropped. - ZernioApiError exposes type, param, platform and platformError, plus `body` with the parsed response verbatim for anything not modelled - RateLimitError and ValidationError keep the API's own code and details instead of overwriting them with a generic one, so a Google Ads quota 429 stays distinguishable from a Zernio rate limit - reported by an integrator, first raised 57 releases ago Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Kq8TeJSGdkrGv4evwMBfJ8 --- src/errors.ts | 57 +++++++++++++++++++++++++++------ tests/errors.test.ts | 75 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index a979feb..a7ccf66 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,22 +1,57 @@ /** * Base error class for Zernio API errors */ +/** + * The canonical error envelope the API returns. Everything but `error` is + * optional, and every field is surfaced on ZernioApiError: dropping any of them + * leaves callers unable to tell apart failures the API distinguishes. For a + * Meta pass-through in particular, `platformError.subcode` is the only thing + * that separates a closed messaging window from a blocked recipient, since both + * arrive as code `platform_api_error`. + */ +export interface ZernioErrorBody { + error?: string; + message?: string; + type?: string; + code?: string; + param?: string; + platform?: string; + platformError?: Record; + details?: Record; +} + export class ZernioApiError extends Error { public readonly statusCode: number; public readonly code?: string; public readonly details?: Record; + /** Error class, e.g. invalid_request_error, platform_error, rate_limit_error. */ + public readonly type?: string; + /** The request field that caused the error, when the API names one. */ + public readonly param?: string; + /** Upstream platform, present when type is platform_error. */ + public readonly platform?: string; + /** The upstream platform's own payload, verbatim (Meta: code, subcode, fbtrace_id). */ + public readonly platformError?: Record; + /** The parsed response body exactly as the API sent it, for anything not modelled above. */ + public readonly body?: ZernioErrorBody; constructor( message: string, statusCode: number, code?: string, - details?: Record + details?: Record, + body?: ZernioErrorBody ) { super(message); this.name = 'ZernioApiError'; this.statusCode = statusCode; this.code = code; this.details = details; + this.type = body?.type; + this.param = body?.param; + this.platform = body?.platform; + this.platformError = body?.platformError; + this.body = body; // Maintains proper stack trace for where error was thrown if (Error.captureStackTrace) { @@ -82,9 +117,12 @@ export class RateLimitError extends ZernioApiError { message: string, limit?: number, remaining?: number, - resetAt?: Date + resetAt?: Date, + body?: ZernioErrorBody ) { - super(message, 429, 'rate_limit_exceeded'); + // The envelope's own code wins when the API sent one: a Google Ads quota + // 429 and a Zernio rate limit are different failures. + super(message, 429, body?.code ?? 'rate_limit_exceeded', body?.details, body); this.name = 'RateLimitError'; this.limit = limit; this.remaining = remaining; @@ -106,8 +144,8 @@ export class RateLimitError extends ZernioApiError { export class ValidationError extends ZernioApiError { public readonly fields?: Record; - constructor(message: string, fields?: Record) { - super(message, 400, 'validation_error', { fields }); + constructor(message: string, fields?: Record, body?: ZernioErrorBody) { + super(message, 400, body?.code ?? 'validation_error', body?.details ?? { fields }, body); this.name = 'ValidationError'; this.fields = fields; } @@ -118,7 +156,7 @@ export class ValidationError extends ZernioApiError { */ export function parseApiError( response: Response, - body?: { error?: string; message?: string; code?: string; details?: Record } + body?: ZernioErrorBody ): ZernioApiError { const message = body?.error || body?.message || response.statusText || 'Unknown error'; const code = body?.code; @@ -134,14 +172,15 @@ export function parseApiError( message, limit ? parseInt(limit, 10) : undefined, remaining ? parseInt(remaining, 10) : undefined, - reset ? new Date(parseInt(reset, 10) * 1000) : undefined + reset ? new Date(parseInt(reset, 10) * 1000) : undefined, + body ); } // Handle validation errors if (response.status === 400 && details?.fields) { - return new ValidationError(message, details.fields as Record); + return new ValidationError(message, details.fields as Record, body); } - return new ZernioApiError(message, response.status, code, details); + return new ZernioApiError(message, response.status, code, details, body); } diff --git a/tests/errors.test.ts b/tests/errors.test.ts index df89073..fb8cf23 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -235,3 +235,78 @@ describe('parseApiError', () => { expect(error.statusCode).toBe(500); }); }); + +// Reported by an integrator on 2026-09-14 and first raised 57 releases earlier: +// parseApiError built ZernioApiError from error/code/details only, so of the +// seven canonical envelope fields, type, param, platform and platformError were +// dropped on EVERY endpoint. For Meta sends that is fatal to error handling, +// because a closed messaging window and a blocked recipient both arrive as +// code platform_api_error and only platformError.subcode separates them. +describe('parseApiError carries the whole envelope', () => { + const metaBody = { + error: 'This message is sent outside of allowed window.', + type: 'platform_error', + code: 'platform_api_error', + param: 'text', + platform: 'instagram', + platformError: { code: 10, subcode: 2534022, fbtrace_id: 'Abc123' }, + details: { conversationId: 'c1' }, + }; + + it('surfaces type, param, platform and platformError', () => { + const err = parseApiError(new Response(null, { status: 403 }), metaBody); + expect(err.type).toBe('platform_error'); + expect(err.param).toBe('text'); + expect(err.platform).toBe('instagram'); + expect(err.platformError).toEqual({ code: 10, subcode: 2534022, fbtrace_id: 'Abc123' }); + }); + + it('keeps the subcode that separates one Meta refusal from another', () => { + const err = parseApiError(new Response(null, { status: 403 }), metaBody); + expect((err.platformError as { subcode?: number })?.subcode).toBe(2534022); + }); + + it('attaches the parsed body verbatim for anything not modelled', () => { + const err = parseApiError(new Response(null, { status: 403 }), metaBody); + expect(err.body).toEqual(metaBody); + }); + + it('still fills the fields it always did', () => { + const err = parseApiError(new Response(null, { status: 403 }), metaBody); + expect(err.message).toBe(metaBody.error); + expect(err.statusCode).toBe(403); + expect(err.code).toBe('platform_api_error'); + expect(err.details).toEqual({ conversationId: 'c1' }); + }); + + it('carries the envelope through a 429 too, keeping the API-sent code', () => { + const err = parseApiError(new Response(null, { status: 429 }), { + error: 'Google Ads API error (429): Too many requests.', + type: 'rate_limit_error', + code: 'rate_limited', + details: { quotaExhausted: true, quotaScope: 'DEVELOPER' }, + }); + expect(err.code).toBe('rate_limited'); + expect(err.type).toBe('rate_limit_error'); + expect(err.details).toEqual({ quotaExhausted: true, quotaScope: 'DEVELOPER' }); + }); + + it('carries the envelope through a 400 validation error', () => { + const err = parseApiError(new Response(null, { status: 400 }), { + error: 'Invalid field', + type: 'invalid_request_error', + code: 'invalid_field_value', + param: 'targeting.customLocations', + details: { fields: { foo: ['bad'] } }, + }); + expect(err.param).toBe('targeting.customLocations'); + expect(err.type).toBe('invalid_request_error'); + }); + + it('leaves the new fields undefined when the API sent no body', () => { + const err = parseApiError(new Response(null, { status: 500 })); + expect(err.type).toBeUndefined(); + expect(err.platformError).toBeUndefined(); + expect(err.statusCode).toBe(500); + }); +});