diff --git a/src/api/__tests__/gateway.test.ts b/src/api/__tests__/gateway.test.ts index 252a3c7..8551e00 100644 --- a/src/api/__tests__/gateway.test.ts +++ b/src/api/__tests__/gateway.test.ts @@ -10,7 +10,14 @@ vi.mock('../../config/env-profiles.js', () => ({ })); import { gatewayRequest, substitutePath } from '../gateway.js'; -import { ConflictError, exitCodeFor } from '../../output/error.js'; +import { + ApiError, + AuthError, + ConflictError, + ForbiddenError, + RateLimitError, + exitCodeFor, +} from '../../output/error.js'; const waChannel = { id: 'ch_abc12345', type: 'whatsapp', workspaceId: 'ws_T0000001', @@ -73,6 +80,82 @@ describe('gatewayRequest', () => { .rejects.toThrow(/Invalid parameter/); }); + it('preserves a non-retryable HookMyApp plan-limit error', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ + statusCode: 403, + code: 'CHANNEL_USAGE_LIMIT_EXCEEDED', + message: 'Upgrade your plan or add a top-up to resume.', + }), { status: 403, headers: { 'content-type': 'application/json' } }), + ); + + const err = await gatewayRequest({ channel: waChannel, method: 'GET', path: '/me' }) + .then(() => null, (error: unknown) => error); + expect(err).toBeInstanceOf(ForbiddenError); + expect(err).toMatchObject({ + code: 'CHANNEL_USAGE_LIMIT_EXCEEDED', + statusCode: 403, + userMessage: 'Upgrade your plan or add a top-up to resume.', + }); + expect(exitCodeFor(err)).toBe(3); + }); + + it('preserves a HookMyApp authentication code and exit tier', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ + statusCode: 401, + code: 'ACCESS_TOKEN_INVALID', + message: 'Invalid access token', + }), { status: 401, headers: { 'content-type': 'application/json' } }), + ); + + const err = await gatewayRequest({ channel: waChannel, method: 'GET', path: '/me' }) + .then(() => null, (error: unknown) => error); + expect(err).toBeInstanceOf(AuthError); + expect(err).toMatchObject({ code: 'ACCESS_TOKEN_INVALID', statusCode: 401 }); + expect(exitCodeFor(err)).toBe(4); + }); + + it('preserves a retryable HookMyApp throttle code', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ + statusCode: 429, + code: 'GATEWAY_RATE_LIMITED', + message: 'Rate limit exceeded. Retry shortly.', + retry_after_seconds: 12, + }), { status: 429, headers: { 'content-type': 'application/json', 'retry-after': '1' } }), + ); + + const err = await gatewayRequest({ channel: waChannel, method: 'GET', path: '/me' }) + .then(() => null, (error: unknown) => error); + expect(err).toBeInstanceOf(RateLimitError); + expect(err).toMatchObject({ + code: 'GATEWAY_RATE_LIMITED', + statusCode: 429, + details: { retry_after_seconds: 12 }, + }); + }); + + it('preserves a temporary Meta outage code', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ + statusCode: 503, + code: 'META_TEMPORARILY_UNAVAILABLE', + message: 'Meta is temporarily unavailable. Retry shortly.', + retry_after_seconds: 60, + }), { status: 503, headers: { 'content-type': 'application/json', 'retry-after': '60' } }), + ); + + const err = await gatewayRequest({ channel: waChannel, method: 'GET', path: '/me' }) + .then(() => null, (error: unknown) => error); + expect(err).toBeInstanceOf(ApiError); + expect(err).toMatchObject({ + code: 'META_TEMPORARILY_UNAVAILABLE', + statusCode: 503, + details: { retry_after_seconds: 60 }, + }); + }); + it('explains a Meta account restriction instead of blaming the request body', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( diff --git a/src/api/gateway.ts b/src/api/gateway.ts index bf82512..075d0d5 100644 --- a/src/api/gateway.ts +++ b/src/api/gateway.ts @@ -1,6 +1,14 @@ import { apiClient, isNetworkFailure } from './client.js'; import { getGatewayBaseOverride } from '../config/env-profiles.js'; -import { ApiError, NetworkError, ValidationError, AuthError, ConflictError } from '../output/error.js'; +import { + ApiError, + NetworkError, + ValidationError, + AuthError, + ConflictError, + ForbiddenError, + RateLimitError, +} from '../output/error.js'; import type { Channel } from './channel.js'; import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; @@ -93,6 +101,32 @@ function metaRestrictionMessage(subcode: number | undefined): string | undefined } function mapGatewayError(status: number, body: unknown): never { + const gatewayError = + body && + typeof body === 'object' && + typeof (body as { statusCode?: unknown }).statusCode === 'number' && + typeof (body as { code?: unknown }).code === 'string' && + typeof (body as { message?: unknown }).message === 'string' + ? (body as { code: string; message: string; retry_after_seconds?: unknown }) + : undefined; + if (gatewayError) { + const retryAfter = gatewayError.retry_after_seconds; + const details = + typeof retryAfter === 'number' && Number.isSafeInteger(retryAfter) && retryAfter > 0 + ? { retry_after_seconds: retryAfter } + : undefined; + if (status === 400 || status === 422) { + throw new ValidationError(gatewayError.message, gatewayError.code); + } + if (status === 401) throw new AuthError(gatewayError.message, gatewayError.code); + if (status === 403) throw new ForbiddenError(gatewayError.message, gatewayError.code); + if (status === 409) throw new ConflictError(gatewayError.message, gatewayError.code); + if (status === 429) { + throw new RateLimitError(gatewayError.message, gatewayError.code, details); + } + throw new ApiError(gatewayError.message, status, gatewayError.code, details); + } + // Meta error shape: { error: { message, code, error_subcode, type, ... } } const metaError = body && typeof body === 'object' && 'error' in body diff --git a/src/output/error.ts b/src/output/error.ts index f4bb208..ae0080a 100644 --- a/src/output/error.ts +++ b/src/output/error.ts @@ -66,8 +66,13 @@ export class CliError extends AppError { static override readonly severity: 'sev1' | 'sev2' | 'sev3' = 'sev3'; public exitCode: number = 1; - constructor(userMessage: string, code: string, statusCode?: number) { - super({ message: userMessage, code }); + constructor( + userMessage: string, + code: string, + statusCode?: number, + details?: Record, + ) { + super({ message: userMessage, code, details }); if (statusCode !== undefined) { Object.defineProperty(this, 'statusCode', { value: statusCode, @@ -123,8 +128,11 @@ export class ValidationError extends CliError { export class AuthError extends CliError { static readonly severity = 'sev3' as const; static readonly httpStatus = 401; - constructor(message: string = `Session expired. Run: ${cliCommandPrefix()} login`) { - super(message, 'AUTH_REQUIRED', 401); + constructor( + message: string = `Session expired. Run: ${cliCommandPrefix()} login`, + code = 'AUTH_REQUIRED', + ) { + super(message, code, 401); this.exitCode = 4; } } @@ -175,8 +183,9 @@ export class RateLimitError extends CliError { constructor( message: string = 'Rate limit exceeded. Wait a minute and retry.', code = 'RATE_LIMITED', + details?: Record, ) { - super(message, code, 429); + super(message, code, 429, details); this.exitCode = 6; } } @@ -206,9 +215,14 @@ export class ApiError extends CliError { // `serverCode` (optional) preserves the API's own `body.code` on generic 4xx // fallbacks so agents can branch on it (AIT-151). When absent we fall back to // the historical SERVER_ERROR / API_ERROR codes. - constructor(message: string, statusCode: number, serverCode?: string) { + constructor( + message: string, + statusCode: number, + serverCode?: string, + details?: Record, + ) { const code = serverCode ?? (statusCode >= 500 ? 'SERVER_ERROR' : 'API_ERROR'); - super(message, code, statusCode); + super(message, code, statusCode, details); this.exitCode = 1; } }