Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 48 additions & 9 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
details?: Record<string, unknown>;
}

export class ZernioApiError extends Error {
public readonly statusCode: number;
public readonly code?: string;
public readonly details?: Record<string, unknown>;
/** 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<string, unknown>;
/** 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<string, unknown>
details?: Record<string, unknown>,
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) {
Expand Down Expand Up @@ -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;
Expand All @@ -106,8 +144,8 @@ export class RateLimitError extends ZernioApiError {
export class ValidationError extends ZernioApiError {
public readonly fields?: Record<string, string[]>;

constructor(message: string, fields?: Record<string, string[]>) {
super(message, 400, 'validation_error', { fields });
constructor(message: string, fields?: Record<string, string[]>, body?: ZernioErrorBody) {
super(message, 400, body?.code ?? 'validation_error', body?.details ?? { fields }, body);
this.name = 'ValidationError';
this.fields = fields;
}
Expand All @@ -118,7 +156,7 @@ export class ValidationError extends ZernioApiError {
*/
export function parseApiError(
response: Response,
body?: { error?: string; message?: string; code?: string; details?: Record<string, unknown> }
body?: ZernioErrorBody
): ZernioApiError {
const message = body?.error || body?.message || response.statusText || 'Unknown error';
const code = body?.code;
Expand All @@ -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<string, string[]>);
return new ValidationError(message, details.fields as Record<string, string[]>, body);
}

return new ZernioApiError(message, response.status, code, details);
return new ZernioApiError(message, response.status, code, details, body);
}
75 changes: 75 additions & 0 deletions tests/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading