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..6049998d6 --- /dev/null +++ b/packages/core/src/__tests__/errors-classification.test.ts @@ -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); + }); + }); +}); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 5e98b7a88..a816ade49 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -1,6 +1,5 @@ -/** - * Error types reported through the errorHandler in the client - */ +import type { ErrorClassification } from './types'; + export enum ErrorType { NetworkUnexpectedHTTPCode, NetworkServerLimited, @@ -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 @@ -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; + 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; +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b0d4e9570..ef1eaadd4 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 { @@ -151,6 +154,17 @@ export type Config = { cdnProxy?: string; useSegmentEndpoints?: boolean; // Use if you want to use Segment endpoints errorHandler?: (error: SegmentError) => void; + /** + * Controls how concurrent batch errors are consolidated into a single retry delay. + * - 'lazy' (default): uses the longest wait time (most conservative, fewer retries) + * - 'eager': uses the shortest wait time (more aggressive, retries sooner) + */ + retryStrategy?: 'eager' | 'lazy'; + /** + * When true, automatically triggers a flush when the retry manager's wait + * period expires and transitions back to READY. Disabled by default. + */ + autoFlushOnRetryReady?: boolean; }; export type ClientMethods = { @@ -332,6 +346,50 @@ 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 type BackoffStateData = { + state: 'READY' | 'BACKING_OFF'; + retryCount: number; + nextRetryTime: number; + firstFailureTime: number; +}; + +export type UploadStateData = { + state: 'READY' | 'RATE_LIMITED'; + waitUntilTime: number; + globalRetryCount: number; + firstFailureTime: number | null; +}; + +export type ErrorClassification = { + isRetryable: boolean; + errorType: 'rate_limit' | 'transient' | 'permanent'; + retryAfterSeconds?: number; +}; + export type SegmentAPISettings = { integrations: SegmentAPIIntegrations; edgeFunction?: EdgeFunctionSettings; @@ -340,6 +398,7 @@ export type SegmentAPISettings = { }; metrics?: MetricsOptions; consentSettings?: SegmentAPIConsentSettings; + httpConfig?: HttpConfig; }; export type DestinationMetadata = {