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
85 changes: 84 additions & 1 deletion src/api/__tests__/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand Down
36 changes: 35 additions & 1 deletion src/api/gateway.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down
28 changes: 21 additions & 7 deletions src/output/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
) {
super({ message: userMessage, code, details });
if (statusCode !== undefined) {
Object.defineProperty(this, 'statusCode', {
value: statusCode,
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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<string, unknown>,
) {
super(message, code, 429);
super(message, code, 429, details);
this.exitCode = 6;
}
}
Expand Down Expand Up @@ -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<string, unknown>,
) {
const code = serverCode ?? (statusCode >= 500 ? 'SERVER_ERROR' : 'API_ERROR');
super(message, code, statusCode);
super(message, code, statusCode, details);
this.exitCode = 1;
}
}
Expand Down
Loading