From d46f91514b71d81cae79f73b20b535fa338ea5e6 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Mon, 9 Mar 2026 19:55:58 -0500 Subject: [PATCH 01/12] feat(core): add TAPI error classification types and utilities Add foundation types (HttpConfig, BackoffConfig, RateLimitConfig, ErrorClassification) and error classification utilities (classifyError, parseRetryAfter, translateHTTPError improvements) for TAPI-compliant retry handling. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/errors-classification.test.ts | 243 ++++++++++++++++++ packages/core/src/errors.ts | 94 ++++++- packages/core/src/types.ts | 59 +++++ 3 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/__tests__/errors-classification.test.ts 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 = { From 7c3e84d083c106b8332886c60c7781de0f1c8a21 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Tue, 10 Mar 2026 14:32:51 -0500 Subject: [PATCH 02/12] refactor: remove unused BackoffStateData and UploadStateData types Remnants from the removed BackoffManager and UploadStateMachine classes, never imported anywhere. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/types.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ef1eaadd4..46ac7694a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -370,20 +370,6 @@ export type HttpConfig = { 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'; From fa8be7a0de6bcaf2996ca831d8e154612bf871ac Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Thu, 12 Mar 2026 06:14:39 -0500 Subject: [PATCH 03/12] refactor: derive isRetryable from errorType via class getter Convert ErrorClassification from a plain type to a class with a getter, eliminating redundant isRetryable field that was always derivable from errorType !== 'permanent'. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/errors.ts | 22 ++++++++-------------- packages/core/src/types.ts | 21 ++++++++++++++++----- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index a816ade49..164df9bdd 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -1,4 +1,4 @@ -import type { ErrorClassification } from './types'; +import { ErrorClassification } from './types'; export enum ErrorType { NetworkUnexpectedHTTPCode, @@ -143,33 +143,27 @@ export const classifyError = ( if (override !== undefined) { if (override === 'retry') { return statusCode === 429 - ? { isRetryable: true, errorType: 'rate_limit' } - : { isRetryable: true, errorType: 'transient' }; + ? new ErrorClassification('rate_limit') + : new ErrorClassification('transient'); } - return { isRetryable: false, errorType: 'permanent' }; + return new ErrorClassification('permanent'); } if (statusCode === 429 && config?.rateLimitEnabled !== false) { - return { isRetryable: true, errorType: 'rate_limit' }; + return new ErrorClassification('rate_limit'); } if (statusCode >= 400 && statusCode < 500) { const behavior = config?.default4xxBehavior ?? 'drop'; - return { - isRetryable: behavior === 'retry', - errorType: behavior === 'retry' ? 'transient' : 'permanent', - }; + return new ErrorClassification(behavior === 'retry' ? 'transient' : 'permanent'); } if (statusCode >= 500 && statusCode < 600) { const behavior = config?.default5xxBehavior ?? 'retry'; - return { - isRetryable: behavior === 'retry', - errorType: behavior === 'retry' ? 'transient' : 'permanent', - }; + return new ErrorClassification(behavior === 'retry' ? 'transient' : 'permanent'); } - return { isRetryable: false, errorType: 'permanent' }; + return new ErrorClassification('permanent'); }; /** diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 46ac7694a..b1b15a18f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -370,11 +370,22 @@ export type HttpConfig = { backoffConfig?: BackoffConfig; }; -export type ErrorClassification = { - isRetryable: boolean; - errorType: 'rate_limit' | 'transient' | 'permanent'; - retryAfterSeconds?: number; -}; +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; From f18526c0962d5e118e9d81c63b27cddf0e229c2b Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Thu, 12 Mar 2026 07:09:16 -0500 Subject: [PATCH 04/12] refactor: remove SDD reference from classifyError docstring Co-Authored-By: Claude Opus 4.6 --- packages/core/src/errors.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 164df9bdd..3278cab76 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -118,17 +118,13 @@ export const translateHTTPError = (error: unknown): SegmentError => { }; /** - * Classify an HTTP status code according to TAPI SDD error handling tables. + * Classify an HTTP status code into rate_limit, transient, or permanent. * - * 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 + * 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, From d415790eda21931e9efa701345478c3f9b15ef92 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Thu, 12 Mar 2026 08:11:50 -0500 Subject: [PATCH 05/12] fix: explicitly reject negative Retry-After seconds parseRetryAfter now returns undefined for negative seconds instead of falling through to date parsing where the behavior was ambiguous. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/__tests__/errors-classification.test.ts | 8 ++------ packages/core/src/errors.ts | 3 ++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/core/src/__tests__/errors-classification.test.ts b/packages/core/src/__tests__/errors-classification.test.ts index 6049998d6..7a3df1cae 100644 --- a/packages/core/src/__tests__/errors-classification.test.ts +++ b/packages/core/src/__tests__/errors-classification.test.ts @@ -224,12 +224,8 @@ describe('parseRetryAfter', () => { 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); + expect(parseRetryAfter('-10')).toBeUndefined(); + expect(parseRetryAfter('-1')).toBeUndefined(); }); it('uses custom maxRetryInterval', () => { diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 3278cab76..9b39ad513 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -178,7 +178,8 @@ export const parseRetryAfter = ( // Try parsing as seconds (e.g., "60") const seconds = parseInt(retryAfterValue, 10); - if (!isNaN(seconds) && seconds >= 0) { + if (!isNaN(seconds)) { + if (seconds < 0) return undefined; return Math.min(seconds, maxRetryInterval); } From 403f851f46b5ac15a55f3b5c935170189e9a5d95 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Thu, 12 Mar 2026 10:20:37 -0500 Subject: [PATCH 06/12] fix: strict numeric check in parseRetryAfter, formatting, and test coverage Use regex /^\d+$/ instead of parseInt to prevent date strings starting with digits from being misclassified as seconds. Add test for 429+override='drop' precedence and date-string edge case. Run formatter to fix CI. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/errors-classification.test.ts | 19 +++++++++++++++++ packages/core/src/errors.ts | 21 +++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/core/src/__tests__/errors-classification.test.ts b/packages/core/src/__tests__/errors-classification.test.ts index 7a3df1cae..1a17719ab 100644 --- a/packages/core/src/__tests__/errors-classification.test.ts +++ b/packages/core/src/__tests__/errors-classification.test.ts @@ -30,6 +30,16 @@ describe('classifyError', () => { 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', () => { @@ -220,6 +230,15 @@ describe('parseRetryAfter', () => { 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', () => { diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 9b39ad513..3c671e7ff 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -151,12 +151,16 @@ export const classifyError = ( if (statusCode >= 400 && statusCode < 500) { const behavior = config?.default4xxBehavior ?? 'drop'; - return new ErrorClassification(behavior === 'retry' ? 'transient' : 'permanent'); + 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( + behavior === 'retry' ? 'transient' : 'permanent' + ); } return new ErrorClassification('permanent'); @@ -176,13 +180,18 @@ export const parseRetryAfter = ( ): number | undefined => { if (retryAfterValue === null || retryAfterValue === '') return undefined; - // Try parsing as seconds (e.g., "60") - const seconds = parseInt(retryAfterValue, 10); - if (!isNaN(seconds)) { - if (seconds < 0) return undefined; + // Try parsing as seconds (e.g., "60") — must be all digits to avoid + // misclassifying date strings that start with numbers (e.g., "01 Jan 2026") + if (/^\d+$/.test(retryAfterValue)) { + const seconds = Number(retryAfterValue); return Math.min(seconds, maxRetryInterval); } + // Reject explicitly negative values + if (/^-\d+$/.test(retryAfterValue)) { + return undefined; + } + // Try parsing as HTTP-date (e.g., "Fri, 31 Dec 2026 23:59:59 GMT") const retryDate = new Date(retryAfterValue); if (!isNaN(retryDate.getTime())) { From 1185803ffa77bf7ee9cd5c9334c64ba7d03c0c3a Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Thu, 12 Mar 2026 11:30:23 -0500 Subject: [PATCH 07/12] fix: restore pre-existing comments in errors.ts Restore JSDoc on ErrorType enum and inline comments in translateHTTPError that were inadvertently removed during cleanup. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/errors.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 3c671e7ff..c8c6a98ab 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -1,5 +1,8 @@ import { ErrorClassification } from './types'; +/** + * Error types reported through the errorHandler in the client + */ export enum ErrorType { NetworkUnexpectedHTTPCode, NetworkServerLimited, @@ -98,14 +101,17 @@ 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 From 9cdf348813e6700f2fe3171fb27a8a80dd1e1567 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Thu, 12 Mar 2026 11:48:18 -0500 Subject: [PATCH 08/12] refactor: remove SDD references from test descriptions Co-Authored-By: Claude Opus 4.6 --- .../__tests__/errors-classification.test.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/core/src/__tests__/errors-classification.test.ts b/packages/core/src/__tests__/errors-classification.test.ts index 1a17719ab..324338dce 100644 --- a/packages/core/src/__tests__/errors-classification.test.ts +++ b/packages/core/src/__tests__/errors-classification.test.ts @@ -128,8 +128,8 @@ describe('classifyError', () => { }); }); - describe('SDD-specified overrides', () => { - const sddConfig = { + describe('statusCodeOverrides', () => { + const overrideConfig = { default4xxBehavior: 'drop' as const, default5xxBehavior: 'retry' as const, statusCodeOverrides: { @@ -142,28 +142,28 @@ describe('classifyError', () => { }, }; - it('retries 408 (per SDD)', () => { - const result = classifyError(408, sddConfig); + it('retries 408 (override)', () => { + const result = classifyError(408, overrideConfig); expect(result.isRetryable).toBe(true); }); - it('retries 410 (per SDD)', () => { - const result = classifyError(410, sddConfig); + it('retries 410 (override)', () => { + const result = classifyError(410, overrideConfig); expect(result.isRetryable).toBe(true); }); - it('retries 460 (per SDD)', () => { - const result = classifyError(460, sddConfig); + it('retries 460 (override)', () => { + const result = classifyError(460, overrideConfig); expect(result.isRetryable).toBe(true); }); - it('drops 501 (per SDD)', () => { - const result = classifyError(501, sddConfig); + it('drops 501 (override)', () => { + const result = classifyError(501, overrideConfig); expect(result.isRetryable).toBe(false); }); - it('drops 505 (per SDD)', () => { - const result = classifyError(505, sddConfig); + it('drops 505 (override)', () => { + const result = classifyError(505, overrideConfig); expect(result.isRetryable).toBe(false); }); }); From 8c70ce7d321e54840a5b158fa7901af282b1a19b Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Thu, 12 Mar 2026 12:37:26 -0500 Subject: [PATCH 09/12] refactor: replace regex with Number() in parseRetryAfter Co-Authored-By: Claude Opus 4.6 --- packages/core/src/errors.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index c8c6a98ab..dc7c2b6fe 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -186,16 +186,12 @@ export const parseRetryAfter = ( ): number | undefined => { if (retryAfterValue === null || retryAfterValue === '') return undefined; - // Try parsing as seconds (e.g., "60") — must be all digits to avoid - // misclassifying date strings that start with numbers (e.g., "01 Jan 2026") - if (/^\d+$/.test(retryAfterValue)) { - const seconds = Number(retryAfterValue); - return Math.min(seconds, maxRetryInterval); - } - - // Reject explicitly negative values - if (/^-\d+$/.test(retryAfterValue)) { - return undefined; + // Number() returns NaN for date strings like "01 Jan 2026", + // so this naturally distinguishes seconds from HTTP-date format + const asNumber = Number(retryAfterValue); + if (Number.isFinite(asNumber)) { + if (asNumber < 0) return undefined; + return Math.min(asNumber, maxRetryInterval); } // Try parsing as HTTP-date (e.g., "Fri, 31 Dec 2026 23:59:59 GMT") From db53d51981c6bf40a66ce4c52b2d527c9306c096 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Wed, 18 Mar 2026 17:30:24 -0500 Subject: [PATCH 10/12] style: trim verbose inline comments in errors.ts Co-Authored-By: Claude Opus 4.6 --- packages/core/src/errors.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index dc7c2b6fe..0dd05938d 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -173,12 +173,8 @@ export const classifyError = ( }; /** - * 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 + * 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, @@ -186,15 +182,12 @@ export const parseRetryAfter = ( ): number | undefined => { if (retryAfterValue === null || retryAfterValue === '') return undefined; - // Number() returns NaN for date strings like "01 Jan 2026", - // so this naturally distinguishes seconds from HTTP-date format const asNumber = Number(retryAfterValue); if (Number.isFinite(asNumber)) { if (asNumber < 0) return undefined; return Math.min(asNumber, 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); From a3ac0e5e3f0facbe833387a11269906a388b7bcf Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Thu, 19 Mar 2026 10:54:46 -0500 Subject: [PATCH 11/12] refactor: remove autoFlushOnRetryReady from Config type Redundant with TimerFlushPolicy which already drives periodic flushes. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/types.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b1b15a18f..7d1ab189f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -160,11 +160,6 @@ export type Config = { * - '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 = { From 6b0322611ab74333d0633a2cebdf779e56f198cf Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Thu, 19 Mar 2026 12:54:51 -0500 Subject: [PATCH 12/12] fix: remove retryStrategy from Config, add SDD error tests - Remove retryStrategy config option from Config type (hardcoded to eager in RetryManager downstream) - Add 14 SDD-default config tests verifying all spec status codes (408/410/460=retry, 501/505=drop, 429=rate_limit, etc.) Co-Authored-By: Claude Opus 4.6 --- .../__tests__/errors-classification.test.ts | 100 ++++++++++++++++++ packages/core/src/types.ts | 6 -- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/packages/core/src/__tests__/errors-classification.test.ts b/packages/core/src/__tests__/errors-classification.test.ts index 324338dce..019666b8e 100644 --- a/packages/core/src/__tests__/errors-classification.test.ts +++ b/packages/core/src/__tests__/errors-classification.test.ts @@ -1,6 +1,106 @@ 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 = { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7d1ab189f..aef451e61 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -154,12 +154,6 @@ 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'; }; export type ClientMethods = {