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); + }); +});