Skip to content
Open
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
243 changes: 243 additions & 0 deletions packages/core/src/__tests__/errors-classification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { classifyError, parseRetryAfter } from '../errors';

describe('classifyError', () => {
describe('statusCodeOverrides precedence', () => {
it('uses override for specific status code', () => {
const config = {
default4xxBehavior: 'drop' as const,
statusCodeOverrides: { '400': 'retry' as const },
};
const result = classifyError(400, config);
expect(result.isRetryable).toBe(true);
expect(result.errorType).toBe('transient');
});

it('classifies 429 as rate_limit when overridden to retry', () => {
const config = {
statusCodeOverrides: { '429': 'retry' as const },
};
const result = classifyError(429, config);
expect(result.isRetryable).toBe(true);
expect(result.errorType).toBe('rate_limit');
});

it('marks code as non-retryable when overridden to drop', () => {
const config = {
default5xxBehavior: 'retry' as const,
statusCodeOverrides: { '503': 'drop' as const },
};
const result = classifyError(503, config);
expect(result.isRetryable).toBe(false);
expect(result.errorType).toBe('permanent');
});
});

describe('429 special handling', () => {
it('classifies 429 as rate_limit by default', () => {
const result = classifyError(429);
expect(result.isRetryable).toBe(true);
expect(result.errorType).toBe('rate_limit');
});

it('respects rateLimitEnabled=false', () => {
const config = {
rateLimitEnabled: false,
default4xxBehavior: 'drop' as const,
};
const result = classifyError(429, config);
expect(result.isRetryable).toBe(false);
expect(result.errorType).toBe('permanent');
});
});

describe('4xx default behavior', () => {
it('defaults to drop for 4xx codes', () => {
const result = classifyError(400);
expect(result.isRetryable).toBe(false);
expect(result.errorType).toBe('permanent');
});

it('respects default4xxBehavior=retry', () => {
const config = { default4xxBehavior: 'retry' as const };
const result = classifyError(404, config);
expect(result.isRetryable).toBe(true);
expect(result.errorType).toBe('transient');
});

it('handles various 4xx codes', () => {
[400, 401, 403, 404, 408, 410, 413, 422, 460].forEach((code) => {
const result = classifyError(code);
expect(result.isRetryable).toBe(false);
expect(result.errorType).toBe('permanent');
});
});
});

describe('5xx default behavior', () => {
it('defaults to retry for 5xx codes', () => {
const result = classifyError(500);
expect(result.isRetryable).toBe(true);
expect(result.errorType).toBe('transient');
});

it('respects default5xxBehavior=drop', () => {
const config = { default5xxBehavior: 'drop' as const };
const result = classifyError(503, config);
expect(result.isRetryable).toBe(false);
expect(result.errorType).toBe('permanent');
});

it('handles various 5xx codes', () => {
[500, 501, 502, 503, 504, 505, 508, 511].forEach((code) => {
const result = classifyError(code);
expect(result.isRetryable).toBe(true);
expect(result.errorType).toBe('transient');
});
});
});

describe('edge cases', () => {
it('handles codes outside 4xx/5xx ranges', () => {
[200, 201, 304, 600, 999].forEach((code) => {
const result = classifyError(code);
expect(result.isRetryable).toBe(false);
expect(result.errorType).toBe('permanent');
});
});

it('handles negative status codes', () => {
const result = classifyError(-1);
expect(result.isRetryable).toBe(false);
expect(result.errorType).toBe('permanent');
});

it('handles zero status code', () => {
const result = classifyError(0);
expect(result.isRetryable).toBe(false);
expect(result.errorType).toBe('permanent');
});
});

describe('SDD-specified overrides', () => {
const sddConfig = {
default4xxBehavior: 'drop' as const,
default5xxBehavior: 'retry' as const,
statusCodeOverrides: {
'408': 'retry' as const,
'410': 'retry' as const,
'429': 'retry' as const,
'460': 'retry' as const,
'501': 'drop' as const,
'505': 'drop' as const,
},
};

it('retries 408 (per SDD)', () => {
const result = classifyError(408, sddConfig);
expect(result.isRetryable).toBe(true);
});

it('retries 410 (per SDD)', () => {
const result = classifyError(410, sddConfig);
expect(result.isRetryable).toBe(true);
});

it('retries 460 (per SDD)', () => {
const result = classifyError(460, sddConfig);
expect(result.isRetryable).toBe(true);
});

it('drops 501 (per SDD)', () => {
const result = classifyError(501, sddConfig);
expect(result.isRetryable).toBe(false);
});

it('drops 505 (per SDD)', () => {
const result = classifyError(505, sddConfig);
expect(result.isRetryable).toBe(false);
});
});
});

describe('parseRetryAfter', () => {
describe('seconds format', () => {
it('parses valid seconds', () => {
expect(parseRetryAfter('60')).toBe(60);
});

it('clamps to maxRetryInterval', () => {
expect(parseRetryAfter('999', 300)).toBe(300);
});

it('accepts zero', () => {
expect(parseRetryAfter('0')).toBe(0);
});

it('handles very large numbers', () => {
expect(parseRetryAfter('999999', 300)).toBe(300);
});
});

describe('HTTP-date format', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
});

afterEach(() => {
jest.useRealTimers();
});

it('parses valid HTTP-date', () => {
const result = parseRetryAfter('Thu, 01 Jan 2026 00:01:00 GMT');
expect(result).toBe(60);
});

it('clamps HTTP-date to maxRetryInterval', () => {
const result = parseRetryAfter('Thu, 01 Jan 2026 01:00:00 GMT', 300);
expect(result).toBe(300);
});

it('handles past dates by returning 0', () => {
const result = parseRetryAfter('Wed, 31 Dec 2025 23:59:00 GMT');
expect(result).toBe(0);
});
});

describe('invalid inputs', () => {
it('returns undefined for null', () => {
expect(parseRetryAfter(null)).toBeUndefined();
});

it('returns undefined for empty string', () => {
expect(parseRetryAfter('')).toBeUndefined();
});

it('returns undefined for invalid string', () => {
expect(parseRetryAfter('invalid')).toBeUndefined();
});

it('returns undefined for malformed date', () => {
expect(parseRetryAfter('Not a date')).toBeUndefined();
});
});

describe('edge cases', () => {
it('rejects negative numbers in seconds format', () => {
// Negative seconds are rejected, falls through to date parsing
// '-10' as a date string may parse to a past date, returning 0
const result = parseRetryAfter('-10');
expect(result).toBeDefined();
// Either undefined (invalid date) or 0 (past date) is acceptable
expect(result === undefined || result === 0).toBe(true);
});

it('uses custom maxRetryInterval', () => {
expect(parseRetryAfter('500', 100)).toBe(100);
});

it('handles maxRetryInterval of 0', () => {
expect(parseRetryAfter('60', 0)).toBe(0);
});
});
});
94 changes: 87 additions & 7 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/**
* Error types reported through the errorHandler in the client
*/
import type { ErrorClassification } from './types';

export enum ErrorType {
NetworkUnexpectedHTTPCode,
NetworkServerLimited,
Expand Down Expand Up @@ -99,18 +98,14 @@ export const checkResponseForErrors = (response: Response) => {
* @returns a SegmentError object
*/
export const translateHTTPError = (error: unknown): SegmentError => {
// SegmentError already
if (error instanceof SegmentError) {
return error;
// JSON Deserialization Errors
} else if (error instanceof SyntaxError) {
return new JSONError(
ErrorType.JsonUnableToDeserialize,
error.message,
error
);

// HTTP Errors
} else {
const message =
error instanceof Error
Expand All @@ -121,3 +116,88 @@ export const translateHTTPError = (error: unknown): SegmentError => {
return new NetworkError(-1, message, error);
}
};

/**
* Classify an HTTP status code according to TAPI SDD error handling tables.
*
* Precedence order:
* 1. statusCodeOverrides - explicit overrides for specific codes
* 2. 429 special handling - rate limiting (if rateLimitEnabled !== false)
* 3. default4xxBehavior/default5xxBehavior - defaults for ranges
* 4. fallback - non-retryable permanent error
*
* @param statusCode - HTTP status code to classify
* @param config - Optional configuration for error classification
* @returns Classification with isRetryable flag and errorType
*/
export const classifyError = (
statusCode: number,
config?: {
default4xxBehavior?: 'drop' | 'retry';
default5xxBehavior?: 'drop' | 'retry';
statusCodeOverrides?: Record<string, 'drop' | 'retry'>;
rateLimitEnabled?: boolean;
}
): ErrorClassification => {
const override = config?.statusCodeOverrides?.[statusCode.toString()];
if (override !== undefined) {
if (override === 'retry') {
return statusCode === 429
? { isRetryable: true, errorType: 'rate_limit' }
: { isRetryable: true, errorType: 'transient' };
}
return { isRetryable: false, errorType: 'permanent' };
}

if (statusCode === 429 && config?.rateLimitEnabled !== false) {
return { isRetryable: true, errorType: 'rate_limit' };
}

if (statusCode >= 400 && statusCode < 500) {
const behavior = config?.default4xxBehavior ?? 'drop';
return {
isRetryable: behavior === 'retry',
errorType: behavior === 'retry' ? 'transient' : 'permanent',
};
}

if (statusCode >= 500 && statusCode < 600) {
const behavior = config?.default5xxBehavior ?? 'retry';
return {
isRetryable: behavior === 'retry',
errorType: behavior === 'retry' ? 'transient' : 'permanent',
};
}

return { isRetryable: false, errorType: 'permanent' };
};

/**
* Parse Retry-After header value from HTTP response.
* Supports both seconds format ("60") and HTTP-date format ("Fri, 31 Dec 2026 23:59:59 GMT").
*
* @param retryAfterValue - Value from Retry-After header (null if not present)
* @param maxRetryInterval - Maximum allowed retry interval in seconds (default: 300)
* @returns Parsed delay in seconds, clamped to maxRetryInterval, or undefined if invalid
*/
export const parseRetryAfter = (
retryAfterValue: string | null,
maxRetryInterval = 300
): number | undefined => {
if (retryAfterValue === null || retryAfterValue === '') return undefined;

// Try parsing as seconds (e.g., "60")
const seconds = parseInt(retryAfterValue, 10);
if (!isNaN(seconds) && seconds >= 0) {
return Math.min(seconds, maxRetryInterval);
}

// Try parsing as HTTP-date (e.g., "Fri, 31 Dec 2026 23:59:59 GMT")
const retryDate = new Date(retryAfterValue);
if (!isNaN(retryDate.getTime())) {
const secondsUntil = Math.ceil((retryDate.getTime() - Date.now()) / 1000);
return Math.min(Math.max(secondsUntil, 0), maxRetryInterval);
}

return undefined;
};
Loading