diff --git a/packages/core/src/__tests__/errors-classification.test.ts b/packages/core/src/__tests__/errors-classification.test.ts new file mode 100644 index 000000000..019666b8e --- /dev/null +++ b/packages/core/src/__tests__/errors-classification.test.ts @@ -0,0 +1,358 @@ +import { classifyError, parseRetryAfter } from '../errors'; + +describe('classifyError', () => { + describe('SDD-default config (statusCodeOverrides per spec)', () => { + 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, + }, + rateLimitEnabled: true, + }; + + it('retries 408 (Request Timeout)', () => { + const result = classifyError(408, sddConfig); + expect(result.isRetryable).toBe(true); + expect(result.errorType).toBe('transient'); + }); + + it('retries 410 (Gone)', () => { + const result = classifyError(410, sddConfig); + expect(result.isRetryable).toBe(true); + expect(result.errorType).toBe('transient'); + }); + + it('retries 460 (Client timeout)', () => { + const result = classifyError(460, sddConfig); + expect(result.isRetryable).toBe(true); + expect(result.errorType).toBe('transient'); + }); + + it('rate-limits 429 (Too Many Requests)', () => { + const result = classifyError(429, sddConfig); + expect(result.isRetryable).toBe(true); + expect(result.errorType).toBe('rate_limit'); + }); + + it('drops 400 (Bad Request)', () => { + const result = classifyError(400, sddConfig); + expect(result.isRetryable).toBe(false); + expect(result.errorType).toBe('permanent'); + }); + + it('drops 401 (Unauthorized)', () => { + const result = classifyError(401, sddConfig); + expect(result.isRetryable).toBe(false); + expect(result.errorType).toBe('permanent'); + }); + + it('drops 413 (Payload Too Large)', () => { + const result = classifyError(413, sddConfig); + expect(result.isRetryable).toBe(false); + expect(result.errorType).toBe('permanent'); + }); + + it('drops 501 (Not Implemented)', () => { + const result = classifyError(501, sddConfig); + expect(result.isRetryable).toBe(false); + expect(result.errorType).toBe('permanent'); + }); + + it('drops 505 (HTTP Version Not Supported)', () => { + const result = classifyError(505, sddConfig); + expect(result.isRetryable).toBe(false); + expect(result.errorType).toBe('permanent'); + }); + + it('retries 500 (Internal Server Error)', () => { + const result = classifyError(500, sddConfig); + expect(result.isRetryable).toBe(true); + expect(result.errorType).toBe('transient'); + }); + + it('retries 502 (Bad Gateway)', () => { + const result = classifyError(502, sddConfig); + expect(result.isRetryable).toBe(true); + expect(result.errorType).toBe('transient'); + }); + + it('retries 503 (Service Unavailable)', () => { + const result = classifyError(503, sddConfig); + expect(result.isRetryable).toBe(true); + expect(result.errorType).toBe('transient'); + }); + + it('retries 504 (Gateway Timeout)', () => { + const result = classifyError(504, sddConfig); + expect(result.isRetryable).toBe(true); + expect(result.errorType).toBe('transient'); + }); + + it('retries 508 (Loop Detected)', () => { + const result = classifyError(508, sddConfig); + expect(result.isRetryable).toBe(true); + expect(result.errorType).toBe('transient'); + }); + }); + + 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'); + }); + + it('overrides 429 to permanent when set to drop', () => { + const config = { + rateLimitEnabled: true, + statusCodeOverrides: { '429': 'drop' as const }, + }; + const result = classifyError(429, 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('statusCodeOverrides', () => { + const overrideConfig = { + 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 (override)', () => { + const result = classifyError(408, overrideConfig); + expect(result.isRetryable).toBe(true); + }); + + it('retries 410 (override)', () => { + const result = classifyError(410, overrideConfig); + expect(result.isRetryable).toBe(true); + }); + + it('retries 460 (override)', () => { + const result = classifyError(460, overrideConfig); + expect(result.isRetryable).toBe(true); + }); + + it('drops 501 (override)', () => { + const result = classifyError(501, overrideConfig); + expect(result.isRetryable).toBe(false); + }); + + it('drops 505 (override)', () => { + const result = classifyError(505, overrideConfig); + 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(); + }); + + it('does not misparse date strings starting with digits as seconds', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-01-01T00:00:00Z')); + // "01 Jan 2026 00:01:00 GMT" starts with digits but is an HTTP-date + const result = parseRetryAfter('01 Jan 2026 00:01:00 GMT'); + expect(result).toBe(60); // parsed as date, not as "1 second" + jest.useRealTimers(); + }); + }); + + describe('edge cases', () => { + it('rejects negative numbers in seconds format', () => { + expect(parseRetryAfter('-10')).toBeUndefined(); + expect(parseRetryAfter('-1')).toBeUndefined(); + }); + + it('uses custom maxRetryInterval', () => { + expect(parseRetryAfter('500', 100)).toBe(100); + }); + + it('handles maxRetryInterval of 0', () => { + expect(parseRetryAfter('60', 0)).toBe(0); + }); + }); +}); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 5e98b7a88..0dd05938d 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -1,3 +1,5 @@ +import { ErrorClassification } from './types'; + /** * Error types reported through the errorHandler in the client */ @@ -109,7 +111,6 @@ export const translateHTTPError = (error: unknown): SegmentError => { error.message, error ); - // HTTP Errors } else { const message = @@ -121,3 +122,77 @@ export const translateHTTPError = (error: unknown): SegmentError => { return new NetworkError(-1, message, error); } }; + +/** + * Classify an HTTP status code into rate_limit, transient, or permanent. + * + * Precedence: + * 1. statusCodeOverrides — explicit per-code overrides + * 2. 429 — rate limiting (if rateLimitEnabled !== false) + * 3. default4xxBehavior / default5xxBehavior — range defaults + * 4. Fallback — permanent (non-retryable) + */ +export const classifyError = ( + statusCode: number, + config?: { + default4xxBehavior?: 'drop' | 'retry'; + default5xxBehavior?: 'drop' | 'retry'; + statusCodeOverrides?: Record; + rateLimitEnabled?: boolean; + } +): ErrorClassification => { + const override = config?.statusCodeOverrides?.[statusCode.toString()]; + if (override !== undefined) { + if (override === 'retry') { + return statusCode === 429 + ? new ErrorClassification('rate_limit') + : new ErrorClassification('transient'); + } + return new ErrorClassification('permanent'); + } + + if (statusCode === 429 && config?.rateLimitEnabled !== false) { + return new ErrorClassification('rate_limit'); + } + + if (statusCode >= 400 && statusCode < 500) { + const behavior = config?.default4xxBehavior ?? 'drop'; + return new ErrorClassification( + behavior === 'retry' ? 'transient' : 'permanent' + ); + } + + if (statusCode >= 500 && statusCode < 600) { + const behavior = config?.default5xxBehavior ?? 'retry'; + return new ErrorClassification( + behavior === 'retry' ? 'transient' : 'permanent' + ); + } + + return new ErrorClassification('permanent'); +}; + +/** + * Parse Retry-After header value (seconds or HTTP-date format). + * Returns 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; + + const asNumber = Number(retryAfterValue); + if (Number.isFinite(asNumber)) { + if (asNumber < 0) return undefined; + return Math.min(asNumber, maxRetryInterval); + } + + 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; +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b0d4e9570..aef451e61 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -35,6 +35,9 @@ interface BaseEventType { integrations?: SegmentAPIIntegrations; _metadata?: DestinationMetadata; enrichment?: EnrichmentClosure; + + /** Internal: timestamp (ms) when event was added to queue. Stripped before upload. */ + _queuedAt?: number; } export interface TrackEventType extends BaseEventType { @@ -332,6 +335,47 @@ export interface EdgeFunctionSettings { version: string; } +export type RateLimitConfig = { + enabled: boolean; + maxRetryCount: number; + maxRetryInterval: number; + maxRateLimitDuration: number; +}; + +export type BackoffConfig = { + enabled: boolean; + maxRetryCount: number; + baseBackoffInterval: number; + maxBackoffInterval: number; + maxTotalBackoffDuration: number; + jitterPercent: number; + default4xxBehavior: 'drop' | 'retry'; + default5xxBehavior: 'drop' | 'retry'; + statusCodeOverrides: Record; +}; + +export type HttpConfig = { + rateLimitConfig?: RateLimitConfig; + backoffConfig?: BackoffConfig; +}; + +export class ErrorClassification { + readonly errorType: 'rate_limit' | 'transient' | 'permanent'; + readonly retryAfterSeconds?: number; + + constructor( + errorType: 'rate_limit' | 'transient' | 'permanent', + retryAfterSeconds?: number + ) { + this.errorType = errorType; + this.retryAfterSeconds = retryAfterSeconds; + } + + get isRetryable(): boolean { + return this.errorType !== 'permanent'; + } +} + export type SegmentAPISettings = { integrations: SegmentAPIIntegrations; edgeFunction?: EdgeFunctionSettings; @@ -340,6 +384,7 @@ export type SegmentAPISettings = { }; metrics?: MetricsOptions; consentSettings?: SegmentAPIConsentSettings; + httpConfig?: HttpConfig; }; export type DestinationMetadata = {