Skip to content

Commit fb32749

Browse files
Merge pull request #1160 from Baskarayelu/feat/issue-1144-error-envelope
[#1144] Standardize the API error envelope
2 parents a4a9e94 + 1f5cec7 commit fb32749

5 files changed

Lines changed: 586 additions & 50 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
import {
3+
boundedRetryAfterMs,
4+
isPublicErrorCode,
5+
normalizeError,
6+
normalizePublicCode,
7+
publicCodeForStatus,
8+
safePublicMessage,
9+
safeValidationDetails,
10+
} from './errorEnvelopePolicy.js';
11+
12+
describe('error envelope policy', () => {
13+
it('maps all supported HTTP statuses to stable public codes', () => {
14+
const expected: Array<[number, string]> = [
15+
[400, 'BAD_REQUEST'], [401, 'UNAUTHORIZED'], [402, 'PAYMENT_REQUIRED'],
16+
[403, 'FORBIDDEN'], [404, 'NOT_FOUND'], [408, 'REQUEST_TIMEOUT'],
17+
[409, 'CONFLICT'], [413, 'REQUEST_BODY_TOO_LARGE'], [415, 'UNSUPPORTED_MEDIA_TYPE'],
18+
[422, 'UNPROCESSABLE_ENTITY'], [429, 'TOO_MANY_REQUESTS'], [500, 'INTERNAL_SERVER_ERROR'],
19+
[502, 'BAD_GATEWAY'], [503, 'SERVICE_UNAVAILABLE'], [504, 'GATEWAY_TIMEOUT'],
20+
];
21+
for (const [status, code] of expected) expect(publicCodeForStatus(status)).toBe(code);
22+
});
23+
24+
it('uses safe fallback codes for unknown statuses and invalid supplied codes', () => {
25+
expect(publicCodeForStatus(501)).toBe('INTERNAL_SERVER_ERROR');
26+
expect(publicCodeForStatus(418)).toBe('BAD_REQUEST');
27+
expect(normalizePublicCode('NOT_A_PUBLIC_CODE', 502)).toBe('BAD_GATEWAY');
28+
expect(normalizePublicCode('CONFLICT', 500)).toBe('CONFLICT');
29+
expect(isPublicErrorCode('NOT_FOUND')).toBe(true);
30+
expect(isPublicErrorCode('database_error')).toBe(false);
31+
});
32+
33+
it('preserves trusted application messages', () => {
34+
expect(safePublicMessage('Wallet is suspended', 403, true)).toBe('Wallet is suspended');
35+
expect(safePublicMessage('', 403, true)).toBe('Request failed');
36+
expect(safePublicMessage('body too large', 413, true)).toBe('Request body too large');
37+
});
38+
39+
it('hides unknown production failures and sensitive development messages', () => {
40+
expect(safePublicMessage('database password=secret', 500, false, true)).toBe('Internal server error');
41+
expect(safePublicMessage('connection string postgres://...', 502, false, true)).toBe('Internal server error');
42+
expect(safePublicMessage('upstream unavailable', 502, false, false)).toBe('Internal server error');
43+
expect(safePublicMessage('bad input', 400, false, true)).toBe('bad input');
44+
});
45+
46+
it('normalizes and bounds validation details', () => {
47+
expect(safeValidationDetails([
48+
{ field: 'body.email', message: 'Invalid email', code: 'INVALID_FORMAT' },
49+
{ field: 4, message: 'ignored', code: 'BAD' },
50+
null,
51+
])).toEqual([{ field: 'body.email', message: 'Invalid email', code: 'INVALID_FORMAT' }]);
52+
expect(safeValidationDetails([])).toBeUndefined();
53+
expect(safeValidationDetails('not-details')).toBeUndefined();
54+
expect(safeValidationDetails([{ field: 'x', message: 'y' }])).toBeUndefined();
55+
});
56+
57+
it('bounds untrusted retry metadata to one day', () => {
58+
expect(boundedRetryAfterMs(0)).toBe(0);
59+
expect(boundedRetryAfterMs(12.9)).toBe(12);
60+
expect(boundedRetryAfterMs(999_999_999)).toBe(86_400_000);
61+
expect(boundedRetryAfterMs(-1)).toBeUndefined();
62+
expect(boundedRetryAfterMs(Number.NaN)).toBeUndefined();
63+
expect(boundedRetryAfterMs('100')).toBeUndefined();
64+
});
65+
66+
it('normalizes trusted validation errors into the versioned contract', () => {
67+
expect(normalizeError({
68+
statusCode: 422,
69+
code: 'VALIDATION_ERROR',
70+
message: 'Request validation failed',
71+
details: [{ field: 'body.amount', message: 'Required', code: 'INVALID_TYPE' }],
72+
trusted: true,
73+
})).toEqual({
74+
statusCode: 422,
75+
code: 'VALIDATION_ERROR',
76+
message: 'Request validation failed',
77+
details: [{ field: 'body.amount', message: 'Required', code: 'INVALID_TYPE' }],
78+
});
79+
});
80+
81+
it('normalizes rate-limit metadata without exposing unsupported fields', () => {
82+
expect(normalizeError({ statusCode: 429, message: 'Too many requests', retryAfterMs: 5_000, trusted: true })).toEqual({
83+
statusCode: 429,
84+
code: 'TOO_MANY_REQUESTS',
85+
message: 'Too many requests',
86+
retryAfterMs: 5_000,
87+
});
88+
});
89+
90+
it('uses generic messages for unknown client errors in production', () => {
91+
const result = normalizeError({ statusCode: 400, message: 'private internal detail', trusted: false, development: false });
92+
expect(result).toMatchObject({ code: 'BAD_REQUEST', message: 'Request failed' });
93+
});
94+
95+
it('keeps safe developer diagnostics only for non-sensitive client failures', () => {
96+
expect(normalizeError({ statusCode: 400, message: 'field x is invalid', trusted: false, development: true }).message).toBe('field x is invalid');
97+
expect(normalizeError({ statusCode: 500, message: 'field x is invalid', trusted: false, development: true }).message).toBe('Internal server error');
98+
});
99+
100+
it('truncates oversized validation values', () => {
101+
const result = safeValidationDetails([{ field: 'f'.repeat(500), message: 'm'.repeat(800), code: 'c'.repeat(200) }]);
102+
expect(result?.[0].field).toHaveLength(200);
103+
expect(result?.[0].message).toHaveLength(500);
104+
expect(result?.[0].code).toHaveLength(100);
105+
});
106+
});

src/errors/errorEnvelopePolicy.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { ValidationErrorDetail } from '../middleware/validate.js';
2+
3+
export const PUBLIC_ERROR_CODES = [
4+
'BAD_REQUEST',
5+
'UNAUTHORIZED',
6+
'PAYMENT_REQUIRED',
7+
'FORBIDDEN',
8+
'NOT_FOUND',
9+
'REQUEST_TIMEOUT',
10+
'CONFLICT',
11+
'REQUEST_BODY_TOO_LARGE',
12+
'UNSUPPORTED_MEDIA_TYPE',
13+
'UNPROCESSABLE_ENTITY',
14+
'TOO_MANY_REQUESTS',
15+
'INTERNAL_SERVER_ERROR',
16+
'BAD_GATEWAY',
17+
'SERVICE_UNAVAILABLE',
18+
'GATEWAY_TIMEOUT',
19+
'VALIDATION_ERROR',
20+
'INVALID_BODY',
21+
'INVALID_QUERY',
22+
'INVALID_PARAMS',
23+
'INVALID_VALUE',
24+
'INSUFFICIENT_BALANCE',
25+
'NETWORK_UNAVAILABLE',
26+
'NETWORK_MISMATCH',
27+
'SOROBAN_RPC_TIMEOUT',
28+
'SOROBAN_RPC_ERROR',
29+
'BILLING_DEDUCTION_FAILED',
30+
'BILLING_REQUEST_NOT_FOUND',
31+
'DEVELOPER_NOT_FOUND',
32+
'API_ACCESS_FORBIDDEN',
33+
'API_KEY_NOT_FOUND',
34+
'API_KEY_FORBIDDEN',
35+
'NOT_AUTHENTICATED',
36+
'REFRESH_FAILED',
37+
'REVOKE_FAILED',
38+
'VAULT_NOT_FOUND',
39+
'INTERNAL_ERROR',
40+
] as const;
41+
42+
export type PublicErrorCode = typeof PUBLIC_ERROR_CODES[number];
43+
44+
const codeByStatus: Record<number, PublicErrorCode> = {
45+
400: 'BAD_REQUEST',
46+
401: 'UNAUTHORIZED',
47+
402: 'PAYMENT_REQUIRED',
48+
403: 'FORBIDDEN',
49+
404: 'NOT_FOUND',
50+
408: 'REQUEST_TIMEOUT',
51+
409: 'CONFLICT',
52+
413: 'REQUEST_BODY_TOO_LARGE',
53+
415: 'UNSUPPORTED_MEDIA_TYPE',
54+
422: 'UNPROCESSABLE_ENTITY',
55+
429: 'TOO_MANY_REQUESTS',
56+
500: 'INTERNAL_SERVER_ERROR',
57+
502: 'BAD_GATEWAY',
58+
503: 'SERVICE_UNAVAILABLE',
59+
504: 'GATEWAY_TIMEOUT',
60+
};
61+
62+
const sensitiveMessage = /(?:password|secret|token|authorization|stack|postgres|sql|database url|connection string)/i;
63+
64+
export function isPublicErrorCode(value: unknown): value is PublicErrorCode {
65+
return typeof value === 'string' && (PUBLIC_ERROR_CODES as readonly string[]).includes(value);
66+
}
67+
68+
export function publicCodeForStatus(status: number): PublicErrorCode {
69+
return codeByStatus[status] ?? (status >= 500 ? 'INTERNAL_SERVER_ERROR' : 'BAD_REQUEST');
70+
}
71+
72+
export function normalizePublicCode(value: unknown, status: number): PublicErrorCode {
73+
return isPublicErrorCode(value) ? value : publicCodeForStatus(status);
74+
}
75+
76+
export function safePublicMessage(message: unknown, status: number, isTrustedError: boolean, isDevelopment = false): string {
77+
if (status === 413) return 'Request body too large';
78+
if (isTrustedError && typeof message === 'string' && message.trim() !== '') return message;
79+
if (isDevelopment && status < 500 && typeof message === 'string' && message.trim() !== '' && !sensitiveMessage.test(message)) return message;
80+
return status >= 500 ? 'Internal server error' : 'Request failed';
81+
}
82+
83+
export function safeValidationDetails(value: unknown): ValidationErrorDetail[] | undefined {
84+
if (!Array.isArray(value)) return undefined;
85+
const details = value.filter((item): item is ValidationErrorDetail => {
86+
if (!item || typeof item !== 'object') return false;
87+
const candidate = item as Record<string, unknown>;
88+
return typeof candidate.field === 'string' && typeof candidate.message === 'string' && typeof candidate.code === 'string';
89+
}).map((detail) => ({
90+
field: detail.field.slice(0, 200),
91+
message: detail.message.slice(0, 500),
92+
code: detail.code.slice(0, 100),
93+
}));
94+
return details.length > 0 ? details : undefined;
95+
}
96+
97+
export function boundedRetryAfterMs(value: unknown): number | undefined {
98+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined;
99+
return Math.min(Math.floor(value), 86_400_000);
100+
}
101+
102+
export interface NormalizedError {
103+
statusCode: number;
104+
code: PublicErrorCode;
105+
message: string;
106+
details?: ValidationErrorDetail[];
107+
retryAfterMs?: number;
108+
}
109+
110+
export function normalizeError(input: {
111+
statusCode: number;
112+
code?: unknown;
113+
message?: unknown;
114+
details?: unknown;
115+
retryAfterMs?: unknown;
116+
trusted: boolean;
117+
development?: boolean;
118+
}): NormalizedError {
119+
const normalized: NormalizedError = {
120+
statusCode: input.statusCode,
121+
code: normalizePublicCode(input.code, input.statusCode),
122+
message: safePublicMessage(input.message, input.statusCode, input.trusted, input.development ?? false),
123+
};
124+
const details = safeValidationDetails(input.details);
125+
const retryAfterMs = boundedRetryAfterMs(input.retryAfterMs);
126+
if (details) normalized.details = details;
127+
if (retryAfterMs !== undefined) normalized.retryAfterMs = retryAfterMs;
128+
return normalized;
129+
}

0 commit comments

Comments
 (0)