diff --git a/src/http-request.ts b/src/http-request.ts index 96bc604a..0a4242ec 100644 --- a/src/http-request.ts +++ b/src/http-request.ts @@ -14,6 +14,8 @@ * - Zero dependencies on external HTTP libraries. */ +import type { Readable } from 'node:stream' + import { safeDelete } from './fs.js' let _fs: typeof import('node:fs') | undefined @@ -118,7 +120,17 @@ export interface HttpHooks { export interface HttpRequestOptions { /** * Request body to send. - * Can be a string (e.g., JSON) or Buffer (e.g., binary data). + * Can be a string, Buffer, or Readable stream. + * + * When a Readable stream is provided, it is piped directly to the request. + * If the stream has a `getHeaders()` method (duck-typed, e.g., the `form-data` + * npm package), its headers (Content-Type with boundary) are automatically + * merged into the request headers. + * + * **Note:** Streaming bodies are one-shot — they cannot be replayed. Using a + * Readable body with `retries > 0` throws an error. Buffer the body as a + * string/Buffer if retries are needed. Redirects are also disabled for + * streaming bodies since the stream is consumed on the first request. * * @example * ```ts @@ -135,9 +147,18 @@ export interface HttpRequestOptions { * method: 'POST', * body: buffer * }) + * + * // Stream form-data (npm package, not native FormData) + * import FormData from 'form-data' + * const form = new FormData() + * form.append('file', createReadStream('data.json')) + * await httpRequest('https://api.example.com/upload', { + * method: 'POST', + * body: form // auto-merges form.getHeaders() + * }) * ``` */ - body?: Buffer | string | undefined + body?: Buffer | Readable | string | undefined /** * Custom CA certificates for TLS connections. * When provided, these certificates are combined with the default trust @@ -241,6 +262,44 @@ export interface HttpRequestOptions { * ``` */ method?: string | undefined + /** + * Callback invoked before each retry attempt. + * Allows customizing retry behavior per-attempt (e.g., skip 4xx, honor Retry-After). + * + * @param attempt - Current retry attempt number (1-based) + * @param error - The error that triggered the retry (HttpResponseError for HTTP errors) + * @param delay - The calculated delay in ms before next retry + * @returns `false` to stop retrying and rethrow, + * a `number` to override the delay (ms), + * or `undefined` to use the calculated delay + * + * @example + * ```ts + * await httpRequest('https://api.example.com/data', { + * retries: 3, + * throwOnError: true, + * onRetry: (attempt, error, delay) => { + * // Don't retry client errors (except 429) + * if (error instanceof HttpResponseError) { + * if (error.response.status === 429) { + * const retryAfter = parseRetryAfter(error.response.headers['retry-after']) + * return retryAfter ?? undefined + * } + * if (error.response.status >= 400 && error.response.status < 500) { + * return false + * } + * } + * } + * }) + * ``` + */ + onRetry?: + | (( + attempt: number, + error: unknown, + delay: number, + ) => boolean | number | undefined) + | undefined /** * Number of retry attempts for failed requests. * Uses exponential backoff: delay = `retryDelay` * 2^attempt. @@ -273,6 +332,23 @@ export interface HttpRequestOptions { * ``` */ retryDelay?: number | undefined + /** + * When true, non-2xx HTTP responses throw an `HttpResponseError` instead + * of resolving with `response.ok === false`. This makes HTTP error + * responses eligible for retry via the `retries` option. + * + * @default false + * + * @example + * ```ts + * // Throw on 4xx/5xx responses (enabling retry for 5xx) + * await httpRequest('https://api.example.com/data', { + * throwOnError: true, + * retries: 3 + * }) + * ``` + */ + throwOnError?: boolean | undefined /** * Request timeout in milliseconds. * If the request takes longer than this, it will be aborted. @@ -440,6 +516,122 @@ export async function readIncomingResponse( } } +/** + * Error thrown when an HTTP response has a non-2xx status code + * and `throwOnError` is enabled. Carries the full `HttpResponse` + * so callers can inspect status, headers, and body. + */ +export class HttpResponseError extends Error { + response: HttpResponse + + constructor(response: HttpResponse, message?: string | undefined) { + const statusCode = response.status ?? 'unknown' + const statusMessage = response.statusText || 'No status message' + super(message ?? `HTTP ${statusCode}: ${statusMessage}`) + this.name = 'HttpResponseError' + this.response = response + Error.captureStackTrace(this, HttpResponseError) + } +} + +/** + * Parse a `Retry-After` HTTP header value into milliseconds. + * + * Supports both formats defined in RFC 7231 §7.1.3: + * - **delay-seconds**: integer number of seconds (e.g., `"120"`) + * - **HTTP-date**: an absolute date/time (e.g., `"Fri, 31 Dec 2027 23:59:59 GMT"`) + * + * When the header is an array (multiple values), the first element is used. + * + * @param value - The raw Retry-After header value(s) + * @returns Delay in milliseconds, or `undefined` if the value cannot be parsed + * + * @example + * ```ts + * const delay = parseRetryAfter(response.headers['retry-after']) + * if (delay !== undefined) { + * await new Promise(resolve => setTimeout(resolve, delay)) + * } + * ``` + */ +export function parseRetryAfter( + value: string | string[] | undefined, +): number | undefined { + if (!value) { + return undefined + } + // Handle array of values (take first). + const raw = Array.isArray(value) ? value[0] : value + if (!raw) { + return undefined + } + // Try parsing as seconds (strict integer — reject partial like "10abc"). + const trimmed = raw.trim() + if (/^\d+$/.test(trimmed)) { + const seconds = Number(trimmed) + return seconds * 1000 + } + // Try parsing as HTTP date. + const date = new Date(raw) + if (!Number.isNaN(date.getTime())) { + const delayMs = date.getTime() - Date.now() + if (delayMs > 0) { + return delayMs + } + } + return undefined +} + +/** + * Redact sensitive HTTP headers for safe logging and telemetry. + * + * Replaces values of sensitive headers (Authorization, Cookie, etc.) + * with `[REDACTED]`. Non-sensitive headers are passed through unchanged. + * Array values are joined with `', '`. + * + * @param headers - HTTP headers to sanitize + * @returns A new object with sensitive values redacted + * + * @example + * ```ts + * const safe = sanitizeHeaders({ + * 'authorization': 'Bearer secret', + * 'content-type': 'application/json' + * }) + * // { authorization: '[REDACTED]', 'content-type': 'application/json' } + * ``` + */ +export function sanitizeHeaders( + headers: Record | undefined, +): Record { + if (!headers) { + return {} + } + const sensitiveHeaders = new Set([ + 'authorization', + 'cookie', + 'proxy-authorization', + 'proxy-authenticate', + 'set-cookie', + 'www-authenticate', + ]) + const result: Record = { __proto__: null } as Record< + string, + string + > + for (const key of Object.keys(headers)) { + const value = headers[key] + if (sensitiveHeaders.has(key.toLowerCase())) { + result[key] = '[REDACTED]' + } else if (Array.isArray(value)) { + result[key] = value.join(', ') + } else if (value !== undefined && value !== null) { + result[key] = String(value) + } + } + return result +} + /** * Configuration options for file downloads. */ @@ -1043,14 +1235,52 @@ async function httpRequestAttempt( } = { __proto__: null, ...options } as HttpRequestOptions const startTime = Date.now() + + // Auto-merge FormData headers (Content-Type with boundary). + const streamHeaders = + body && + typeof body === 'object' && + 'getHeaders' in body && + typeof (body as { getHeaders?: unknown }).getHeaders === 'function' + ? (body as { getHeaders: () => Record }).getHeaders() + : undefined + const mergedHeaders = { 'User-Agent': 'socket-registry/1.0', + ...streamHeaders, ...headers, } hooks?.onRequest?.({ method, url, headers: mergedHeaders, timeout }) return await new Promise((resolve, reject) => { + // Settled flag guards all resolve/reject paths so that at most one + // fires, even when destroy() cascades multiple events. + let settled = false + const resolveOnce = (response: HttpResponse) => { + if (settled) { + return + } + settled = true + resolve(response) + } + const rejectOnce = (err: Error) => { + if (settled) { + return + } + settled = true + // Clean up streaming body if still active to avoid leaked descriptors. + if ( + body && + typeof body === 'object' && + typeof (body as { destroy?: unknown }).destroy === 'function' + ) { + ;(body as { destroy: () => void }).destroy() + } + emitResponse({ error: err }) + reject(err) + } + const parsedUrl = new URL(url) const isHttps = parsedUrl.protocol === 'https:' const httpModule = isHttps ? getHttps() : getHttp() @@ -1069,12 +1299,16 @@ async function httpRequestAttempt( } const emitResponse = (info: Partial) => { - hooks?.onResponse?.({ - duration: Date.now() - startTime, - method, - url, - ...info, - }) + try { + hooks?.onResponse?.({ + duration: Date.now() - startTime, + method, + url, + ...info, + }) + } catch { + // User-provided hook threw — swallow to avoid leaving the promise pending. + } } /* c8 ignore start - External HTTP/HTTPS request */ @@ -1088,6 +1322,9 @@ async function httpRequestAttempt( res.statusCode < 400 && res.headers.location ) { + // Drain the redirect response body to free the socket. + res.resume() + emitResponse({ headers: res.headers, status: res.statusCode, @@ -1095,6 +1332,8 @@ async function httpRequestAttempt( }) if (maxRedirects <= 0) { + // Hook already emitted above — reject directly to avoid double-fire. + settled = true reject( new Error( `Too many redirects (exceeded maximum: ${maxRedirects})`, @@ -1109,6 +1348,8 @@ async function httpRequestAttempt( const redirectParsed = new URL(redirectUrl) if (isHttps && redirectParsed.protocol !== 'https:') { + // Hook already emitted above — reject directly to avoid double-fire. + settled = true reject( new Error( `Redirect from HTTPS to HTTP is not allowed: ${redirectUrl}`, @@ -1117,6 +1358,8 @@ async function httpRequestAttempt( return } + // Redirect chaining — Promise adoption handles the inner result. + settled = true resolve( httpRequestAttempt(redirectUrl, { body, @@ -1140,19 +1383,24 @@ async function httpRequestAttempt( totalBytes += chunk.length if (maxResponseSize && totalBytes > maxResponseSize) { res.destroy() + request.destroy() const sizeMB = (totalBytes / (1024 * 1024)).toFixed(2) const maxMB = (maxResponseSize / (1024 * 1024)).toFixed(2) - const err = new Error( - `Response exceeds maximum size limit (${sizeMB}MB > ${maxMB}MB)`, + rejectOnce( + new Error( + `Response exceeds maximum size limit (${sizeMB}MB > ${maxMB}MB)`, + ), ) - emitResponse({ error: err }) - reject(err) return } chunks.push(chunk) }) res.on('end', () => { + if (settled) { + return + } + const responseBody = Buffer.concat(chunks) const ok = res.statusCode !== undefined && @@ -1186,12 +1434,11 @@ async function httpRequestAttempt( statusText: res.statusMessage, }) - resolve(response) + resolveOnce(response) }) res.on('error', (error: Error) => { - emitResponse({ error }) - reject(error) + rejectOnce(error) }) }, ) @@ -1202,25 +1449,42 @@ async function httpRequestAttempt( method, error as NodeJS.ErrnoException, ) - const enhanced = new Error(message, { cause: error }) - emitResponse({ error: enhanced }) - reject(enhanced) + rejectOnce(new Error(message, { cause: error })) }) request.on('timeout', () => { request.destroy() - const err = new Error( - `${method} request timed out after ${timeout}ms: ${url}\n→ Server did not respond in time.\n→ Try: Increase timeout or check network connectivity.`, + rejectOnce( + new Error( + `${method} request timed out after ${timeout}ms: ${url}\n→ Server did not respond in time.\n→ Try: Increase timeout or check network connectivity.`, + ), ) - emitResponse({ error: err }) - reject(err) }) if (body) { + // Duck-type: streams have a `pipe` method. + if ( + typeof body === 'object' && + typeof (body as { pipe?: unknown }).pipe === 'function' + ) { + // Readable stream (including FormData) — pipe it. + // The error listener is cleaned up implicitly: on failure rejectOnce + // destroys the stream, and on success the stream is fully consumed. + // Both cases prevent further error events. + const stream = body as import('node:stream').Readable + stream.on('error', (err: Error) => { + request.destroy() + rejectOnce(err) + }) + stream.pipe(request) + return + } + // String or Buffer. request.write(body) + request.end() + } else { + request.end() } - - request.end() /* c8 ignore stop */ }) } @@ -1478,6 +1742,8 @@ export async function httpJson( ...headers, } + // httpRequest may throw HttpResponseError when throwOnError is enabled. + // Let it propagate — don't mask it with a generic Error. const response = await httpRequest(url, { body, headers: mergedHeaders, @@ -1547,27 +1813,56 @@ export async function httpRequest( maxRedirects = 5, maxResponseSize, method = 'GET', + onRetry, retries = 0, retryDelay = 1000, + throwOnError = false, timeout = 30_000, } = { __proto__: null, ...options } as HttpRequestOptions + // Readable streams are one-shot — they cannot be replayed on retry or redirect. + // Duck-type check: streams have a `pipe` method. + const isStreamBody = + body !== undefined && + typeof body === 'object' && + typeof (body as { pipe?: unknown }).pipe === 'function' + + if (isStreamBody && retries > 0) { + throw new Error( + 'Streaming body (Readable/FormData) cannot be used with retries. ' + + 'Streams are consumed on first attempt and cannot be replayed. ' + + 'Set retries: 0 or buffer the body as a string/Buffer.', + ) + } + + const attemptOpts: HttpRequestOptions = { + body, + ca, + // Disable redirect following for stream bodies — the stream is consumed + // on the first request and cannot be re-piped to the redirect target. + followRedirects: isStreamBody ? false : followRedirects, + headers, + hooks, + maxRedirects, + maxResponseSize, + method, + timeout, + } + // Retry logic with exponential backoff let lastError: Error | undefined for (let attempt = 0; attempt <= retries; attempt++) { try { // eslint-disable-next-line no-await-in-loop - return await httpRequestAttempt(url, { - body, - ca, - followRedirects, - headers, - hooks, - maxRedirects, - maxResponseSize, - method, - timeout, - }) + const response = await httpRequestAttempt(url, attemptOpts) + + // When throwOnError is enabled, non-2xx responses become errors + // so they can be retried or caught by callers. + if (throwOnError && !response.ok) { + throw new HttpResponseError(response) + } + + return response } catch (e) { lastError = e as Error @@ -1576,10 +1871,26 @@ export async function httpRequest( break } - // Retry with exponential backoff + // Consult onRetry callback if provided. const delayMs = retryDelay * 2 ** attempt - // eslint-disable-next-line no-await-in-loop - await new Promise(resolve => setTimeout(resolve, delayMs)) + if (onRetry) { + const retryResult = onRetry(attempt + 1, e, delayMs) + // false = stop retrying, rethrow immediately. + if (retryResult === false) { + break + } + // A number overrides the delay (clamped to >= 0; NaN falls back to default). + const actualDelay = + typeof retryResult === 'number' && !Number.isNaN(retryResult) + ? Math.max(0, retryResult) + : delayMs + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => setTimeout(resolve, actualDelay)) + } else { + // Default: retry with exponential backoff + // eslint-disable-next-line no-await-in-loop + await new Promise(resolve => setTimeout(resolve, delayMs)) + } } } @@ -1658,6 +1969,8 @@ export async function httpText( ...headers, } + // httpRequest may throw HttpResponseError when throwOnError is enabled. + // Let it propagate — don't mask it with a generic Error. const response = await httpRequest(url, { body, headers: mergedHeaders, diff --git a/test/unit/http-request.test.mts b/test/unit/http-request.test.mts index 57d41e21..95d897e6 100644 --- a/test/unit/http-request.test.mts +++ b/test/unit/http-request.test.mts @@ -20,12 +20,15 @@ import { Writable } from 'node:stream' import { enrichErrorMessage, fetchChecksums, + HttpResponseError, httpDownload, httpJson, httpRequest, httpText, parseChecksums, + parseRetryAfter, readIncomingResponse, + sanitizeHeaders, } from '@socketsecurity/lib/http-request' import type { HttpHookRequestInfo, @@ -189,6 +192,22 @@ beforeAll(async () => { } else if (url === '/no-redirect') { res.writeHead(301, { Location: '/text' }) res.end() + } else if (url === '/upload-form') { + let body = '' + req.on('data', chunk => { + body += chunk.toString() + }) + req.on('end', () => { + const contentType = req.headers['content-type'] || '' + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + contentType, + bodyLength: body.length, + hasMultipart: contentType.includes('multipart'), + }), + ) + }) } else { res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('OK') @@ -2030,11 +2049,11 @@ abc123def456789012345678901234567890123456789012345678901234abcd }, }).catch(() => {}) - expect(responseInfos.length).toBeGreaterThanOrEqual(1) - const sizeError = responseInfos.find(info => - info.error?.message?.includes('exceeds maximum size limit'), + expect(responseInfos).toHaveLength(1) + expect(responseInfos[0]!.error).toBeDefined() + expect(responseInfos[0]!.error!.message).toMatch( + /exceeds maximum size limit/, ) - expect(sizeError).toBeDefined() }) }) @@ -2263,4 +2282,1006 @@ abc123def456789012345678901234567890123456789012345678901234abcd expect(() => response.json()).toThrow() }) }) + + describe('HttpResponseError', () => { + it('should include status and statusText in message', async () => { + const response = await httpRequest(`${httpBaseUrl}/not-found`) + const error = new HttpResponseError(response) + + expect(error.name).toBe('HttpResponseError') + expect(error.message).toContain('404') + expect(error.message).toContain('Not Found') + expect(error.response).toBe(response) + }) + + it('should accept a custom message', async () => { + const response = await httpRequest(`${httpBaseUrl}/server-error`) + const error = new HttpResponseError(response, 'Custom error message') + + expect(error.message).toBe('Custom error message') + expect(error.response.status).toBe(500) + }) + + it('should be an instance of Error', async () => { + const response = await httpRequest(`${httpBaseUrl}/not-found`) + const error = new HttpResponseError(response) + + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(HttpResponseError) + }) + + it('should have a stack trace', async () => { + const response = await httpRequest(`${httpBaseUrl}/not-found`) + const error = new HttpResponseError(response) + + expect(error.stack).toBeDefined() + expect(error.stack).toContain('HttpResponseError') + }) + }) + + describe('throwOnError', () => { + it('should throw HttpResponseError on 404 when enabled', async () => { + try { + await httpRequest(`${httpBaseUrl}/not-found`, { throwOnError: true }) + expect.unreachable('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(HttpResponseError) + const err = e as HttpResponseError + expect(err.response.status).toBe(404) + expect(err.response.text()).toBe('Not Found') + } + }) + + it('should throw HttpResponseError on 500 when enabled', async () => { + try { + await httpRequest(`${httpBaseUrl}/server-error`, { throwOnError: true }) + expect.unreachable('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(HttpResponseError) + const err = e as HttpResponseError + expect(err.response.status).toBe(500) + } + }) + + it('should not throw on 2xx when enabled', async () => { + const response = await httpRequest(`${httpBaseUrl}/json`, { + throwOnError: true, + }) + expect(response.ok).toBe(true) + expect(response.status).toBe(200) + }) + + it('should resolve non-2xx without throwOnError (default)', async () => { + const response = await httpRequest(`${httpBaseUrl}/not-found`) + expect(response.ok).toBe(false) + expect(response.status).toBe(404) + }) + + it('should enable retrying non-2xx responses', async () => { + let attemptCount = 0 + const testServer = http.createServer((_req, res) => { + attemptCount++ + if (attemptCount < 3) { + res.writeHead(500) + res.end('Server Error') + } else { + res.writeHead(200) + res.end('Recovered') + } + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + const response = await httpRequest(`http://localhost:${testPort}/`, { + throwOnError: true, + retries: 3, + retryDelay: 10, + }) + expect(response.text()).toBe('Recovered') + expect(attemptCount).toBe(3) + } finally { + await new Promise(resolve => { + testServer.close(() => resolve()) + }) + } + }) + }) + + describe('onRetry', () => { + it('should call onRetry on each retry attempt', async () => { + const retryCalls: Array<{ attempt: number; delay: number }> = [] + const testServer = http.createServer((req, _res) => { + req.socket.destroy() + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + await httpRequest(`http://localhost:${testPort}/`, { + retries: 2, + retryDelay: 10, + onRetry: (attempt, _error, delay) => { + retryCalls.push({ attempt, delay }) + return undefined + }, + }).catch(() => {}) + + expect(retryCalls).toHaveLength(2) + expect(retryCalls[0]!.attempt).toBe(1) + expect(retryCalls[1]!.attempt).toBe(2) + } finally { + await new Promise(resolve => { + testServer.close(() => resolve()) + }) + } + }) + + it('should stop retrying when onRetry returns false', async () => { + let attemptCount = 0 + const testServer = http.createServer((req, _res) => { + attemptCount++ + req.socket.destroy() + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + await expect( + httpRequest(`http://localhost:${testPort}/`, { + retries: 5, + retryDelay: 10, + onRetry: () => false, + }), + ).rejects.toThrow() + + // Only 1 attempt — onRetry returned false, stopping before any retry + expect(attemptCount).toBe(1) + } finally { + await new Promise(resolve => { + testServer.close(() => resolve()) + }) + } + }) + + it('should override delay when onRetry returns a number', async () => { + const startTime = Date.now() + let attemptCount = 0 + const testServer = http.createServer((req, _res) => { + attemptCount++ + req.socket.destroy() + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + await httpRequest(`http://localhost:${testPort}/`, { + retries: 1, + retryDelay: 5000, // default would be very long + onRetry: () => 10, // override to 10ms + }).catch(() => {}) + + const elapsed = Date.now() - startTime + expect(attemptCount).toBe(2) + // Should be fast since we overrode to 10ms, not 5000ms + expect(elapsed).toBeLessThan(2000) + } finally { + await new Promise(resolve => { + testServer.close(() => resolve()) + }) + } + }) + + it('should receive HttpResponseError when throwOnError + non-2xx', async () => { + let receivedError: unknown + let attemptCount = 0 + const testServer = http.createServer((_req, res) => { + attemptCount++ + if (attemptCount < 3) { + res.writeHead(500) + res.end('Error') + } else { + res.writeHead(200) + res.end('OK') + } + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + const response = await httpRequest(`http://localhost:${testPort}/`, { + throwOnError: true, + retries: 3, + retryDelay: 10, + onRetry: (_attempt, error) => { + receivedError = error + return undefined + }, + }) + + expect(response.text()).toBe('OK') + expect(receivedError).toBeInstanceOf(HttpResponseError) + expect((receivedError as HttpResponseError).response.status).toBe(500) + } finally { + await new Promise(resolve => { + testServer.close(() => resolve()) + }) + } + }) + + it('should skip 4xx retries via onRetry returning false', async () => { + let attemptCount = 0 + const testServer = http.createServer((_req, res) => { + attemptCount++ + res.writeHead(403) + res.end('Forbidden') + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + await expect( + httpRequest(`http://localhost:${testPort}/`, { + throwOnError: true, + retries: 3, + retryDelay: 10, + onRetry: (_attempt, error) => { + if ( + error instanceof HttpResponseError && + error.response.status >= 400 && + error.response.status < 500 + ) { + return false + } + return undefined + }, + }), + ).rejects.toThrow(HttpResponseError) + + // Should not retry 4xx — only 1 attempt + expect(attemptCount).toBe(1) + } finally { + await new Promise(resolve => { + testServer.close(() => resolve()) + }) + } + }) + }) + + describe('parseRetryAfter', () => { + it('should parse integer seconds', () => { + expect(parseRetryAfter('120')).toBe(120_000) + }) + + it('should parse zero seconds', () => { + expect(parseRetryAfter('0')).toBe(0) + }) + + it('should return undefined for undefined input', () => { + expect(parseRetryAfter(undefined)).toBeUndefined() + }) + + it('should return undefined for empty string', () => { + expect(parseRetryAfter('')).toBeUndefined() + }) + + it('should return undefined for empty array', () => { + expect(parseRetryAfter([])).toBeUndefined() + }) + + it('should take first value from array', () => { + expect(parseRetryAfter(['60', '120'])).toBe(60_000) + }) + + it('should parse future HTTP-date', () => { + const future = new Date(Date.now() + 5000).toUTCString() + const result = parseRetryAfter(future)! + + expect(result).toBeGreaterThan(0) + expect(result).toBeLessThanOrEqual(6000) + }) + + it('should return undefined for past HTTP-date', () => { + const past = new Date(Date.now() - 60_000).toUTCString() + expect(parseRetryAfter(past)).toBeUndefined() + }) + + it('should return undefined for negative seconds', () => { + expect(parseRetryAfter('-5')).toBeUndefined() + }) + + it('should return undefined for non-parseable string', () => { + expect(parseRetryAfter('not-a-number-or-date')).toBeUndefined() + }) + }) + + describe('sanitizeHeaders', () => { + it('should redact authorization header', () => { + const result = sanitizeHeaders({ + authorization: 'Bearer secret-token', + 'content-type': 'application/json', + }) + + expect(result['authorization']).toBe('[REDACTED]') + expect(result['content-type']).toBe('application/json') + }) + + it('should redact all sensitive headers', () => { + const result = sanitizeHeaders({ + authorization: 'Bearer token', + cookie: 'session=abc', + 'set-cookie': 'session=abc; Path=/', + 'proxy-authorization': 'Basic xyz', + 'proxy-authenticate': 'Basic', + 'www-authenticate': 'Bearer', + }) + + for (const value of Object.values(result)) { + expect(value).toBe('[REDACTED]') + } + }) + + it('should be case-insensitive for header names', () => { + const result = sanitizeHeaders({ + Authorization: 'Bearer secret', + COOKIE: 'session=abc', + }) + + expect(result['Authorization']).toBe('[REDACTED]') + expect(result['COOKIE']).toBe('[REDACTED]') + }) + + it('should join array values', () => { + const result = sanitizeHeaders({ + accept: ['text/html', 'application/json'], + }) + + expect(result['accept']).toBe('text/html, application/json') + }) + + it('should return empty object for undefined input', () => { + const result = sanitizeHeaders(undefined) + expect(result).toEqual({}) + }) + + it('should skip null and undefined values', () => { + const result = sanitizeHeaders({ + present: 'value', + absent: undefined, + empty: null, + }) + + expect(result['present']).toBe('value') + expect('absent' in result).toBe(false) + expect('empty' in result).toBe(false) + }) + + it('should stringify non-string values', () => { + const result = sanitizeHeaders({ + 'content-length': 42 as unknown, + 'x-flag': true as unknown, + }) + + expect(result['content-length']).toBe('42') + expect(result['x-flag']).toBe('true') + }) + + it('should pass through non-sensitive headers unchanged', () => { + const result = sanitizeHeaders({ + 'content-type': 'application/json', + 'user-agent': 'my-sdk/1.0', + 'x-request-id': 'abc-123', + }) + + expect(result['content-type']).toBe('application/json') + expect(result['user-agent']).toBe('my-sdk/1.0') + expect(result['x-request-id']).toBe('abc-123') + }) + }) + + describe('streaming body', () => { + it('should pipe a Readable stream as request body', async () => { + const { Readable } = await import('node:stream') + const body = Readable.from(Buffer.from('streamed data')) + + const response = await httpRequest(`${httpBaseUrl}/echo-body`, { + method: 'POST', + body: body as import('node:stream').Readable, + }) + + expect(response.text()).toBe('streamed data') + }) + + it('should auto-merge FormData-like getHeaders()', async () => { + const { Readable } = await import('node:stream') + + // Create a minimal FormData-like object. + const boundary = 'test-boundary-123' + const formBody = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="field"', + '', + 'value', + `--${boundary}--`, + ].join('\r\n') + + const stream = Readable.from( + Buffer.from(formBody), + ) as import('node:stream').Readable & { + getHeaders: () => Record + } + stream.getHeaders = () => ({ + 'content-type': `multipart/form-data; boundary=${boundary}`, + }) + + const response = await httpRequest(`${httpBaseUrl}/upload-form`, { + method: 'POST', + body: stream, + }) + + const data = response.json<{ + contentType: string + hasMultipart: boolean + }>() + expect(data.hasMultipart).toBe(true) + expect(data.contentType).toContain('multipart/form-data') + }) + + it('should allow user headers to override stream headers', async () => { + const { Readable } = await import('node:stream') + + const stream = Readable.from( + Buffer.from('override test'), + ) as import('node:stream').Readable & { + getHeaders: () => Record + } + stream.getHeaders = () => ({ + 'content-type': 'multipart/form-data; boundary=auto', + }) + + const response = await httpRequest(`${httpBaseUrl}/upload-form`, { + method: 'POST', + body: stream, + headers: { + 'content-type': 'application/octet-stream', + }, + }) + + const data = response.json<{ + contentType: string + hasMultipart: boolean + }>() + // User header should override getHeaders() + expect(data.contentType).toBe('application/octet-stream') + expect(data.hasMultipart).toBe(false) + }) + + it('should throw when streaming body is used with retries > 0', async () => { + const { Readable } = await import('node:stream') + const body = Readable.from(Buffer.from('data')) + + await expect( + httpRequest(`${httpBaseUrl}/echo-body`, { + method: 'POST', + body: body as import('node:stream').Readable, + retries: 1, + }), + ).rejects.toThrow(/Streaming body.*cannot be used with retries/) + }) + + it('should disable redirects for streaming bodies', async () => { + const { Readable } = await import('node:stream') + const body = Readable.from(Buffer.from('redirect-body')) + + // /redirect returns 302 -> /text, but with a stream body + // redirects are disabled, so we get the raw 302. + const response = await httpRequest(`${httpBaseUrl}/redirect`, { + method: 'POST', + body: body as import('node:stream').Readable, + }) + + // Should get the 302 directly, not follow to /text + expect(response.status).toBe(302) + expect(response.ok).toBe(false) + }) + + it('should handle stream errors without double-firing hooks', async () => { + const { Readable } = await import('node:stream') + const responseInfos: Array< + import('@socketsecurity/lib/http-request').HttpHookResponseInfo + > = [] + + const errorStream = new Readable({ + read() { + // Emit error after a tick to allow piping to start. + process.nextTick(() => { + this.destroy(new Error('stream exploded')) + }) + }, + }) + + await expect( + httpRequest(`${httpBaseUrl}/echo-body`, { + method: 'POST', + body: errorStream as import('node:stream').Readable, + hooks: { + onResponse: info => responseInfos.push(info), + }, + }), + ).rejects.toThrow(/stream exploded/) + + // The settled guard should prevent duplicate onResponse hook calls. + expect(responseInfos).toHaveLength(1) + }) + }) + + describe('onRetry - additional edge cases', () => { + it('should propagate errors thrown by onRetry', async () => { + const testServer = http.createServer((req, _res) => { + req.socket.destroy() + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + await expect( + httpRequest(`http://localhost:${testPort}/`, { + retries: 2, + retryDelay: 10, + onRetry: () => { + throw new Error('onRetry kaboom') + }, + }), + ).rejects.toThrow('onRetry kaboom') + } finally { + await new Promise(resolve => { + testServer.close(() => resolve()) + }) + } + }) + + it('should treat onRetry returning 0 as 0ms delay', async () => { + const startTime = Date.now() + let attemptCount = 0 + const testServer = http.createServer((req, _res) => { + attemptCount++ + req.socket.destroy() + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + await httpRequest(`http://localhost:${testPort}/`, { + retries: 2, + retryDelay: 5000, + onRetry: () => 0, + }).catch(() => {}) + + const elapsed = Date.now() - startTime + expect(attemptCount).toBe(3) + // 0ms override — should be much faster than 5s default + expect(elapsed).toBeLessThan(2000) + } finally { + await new Promise(resolve => { + testServer.close(() => resolve()) + }) + } + }) + + it('should clamp negative onRetry delay to 0', async () => { + const startTime = Date.now() + let attemptCount = 0 + const testServer = http.createServer((req, _res) => { + attemptCount++ + req.socket.destroy() + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + await httpRequest(`http://localhost:${testPort}/`, { + retries: 1, + retryDelay: 5000, + onRetry: () => -100, + }).catch(() => {}) + + const elapsed = Date.now() - startTime + expect(attemptCount).toBe(2) + // Negative clamped to 0 — should be fast + expect(elapsed).toBeLessThan(2000) + } finally { + await new Promise(resolve => { + testServer.close(() => resolve()) + }) + } + }) + + it('should fall back to default delay when onRetry returns NaN', async () => { + const startTime = Date.now() + let attemptCount = 0 + const testServer = http.createServer((req, _res) => { + attemptCount++ + req.socket.destroy() + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + await httpRequest(`http://localhost:${testPort}/`, { + retries: 1, + retryDelay: 10, // small default so test is fast + onRetry: () => NaN, + }).catch(() => {}) + + const elapsed = Date.now() - startTime + expect(attemptCount).toBe(2) + // NaN falls back to default retryDelay (10ms) — should be fast + expect(elapsed).toBeLessThan(2000) + } finally { + await new Promise(resolve => { + testServer.close(() => resolve()) + }) + } + }) + + it('should use onRetry with rate-limited endpoint and Retry-After', async () => { + let attemptCount = 0 + const testServer = http.createServer((_req, res) => { + attemptCount++ + if (attemptCount < 2) { + res.writeHead(429, { 'Retry-After': '0' }) + res.end('Rate limited') + } else { + res.writeHead(200) + res.end('OK') + } + }) + + await new Promise(resolve => { + testServer.listen(0, () => resolve()) + }) + + const address = testServer.address() + const testPort = address && typeof address === 'object' ? address.port : 0 + + try { + const response = await httpRequest(`http://localhost:${testPort}/`, { + throwOnError: true, + retries: 2, + retryDelay: 10, + onRetry: (_attempt, error) => { + if (error instanceof HttpResponseError) { + const retryAfter = parseRetryAfter( + error.response.headers['retry-after'], + ) + return retryAfter ?? undefined + } + return undefined + }, + }) + + expect(response.text()).toBe('OK') + expect(attemptCount).toBe(2) + } finally { + await new Promise(resolve => { + testServer.close(() => resolve()) + }) + } + }) + }) + + describe('throwOnError - additional edge cases', () => { + it('should throw HttpResponseError for 3xx when followRedirects is false', async () => { + try { + await httpRequest(`${httpBaseUrl}/redirect`, { + throwOnError: true, + followRedirects: false, + }) + expect.unreachable('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(HttpResponseError) + expect((e as HttpResponseError).response.status).toBe(302) + } + }) + + it('should propagate HttpResponseError through httpJson', async () => { + try { + await httpJson(`${httpBaseUrl}/not-found`, { + throwOnError: true, + }) + expect.unreachable('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(HttpResponseError) + expect((e as HttpResponseError).response.status).toBe(404) + } + }) + + it('should propagate HttpResponseError through httpText', async () => { + try { + await httpText(`${httpBaseUrl}/server-error`, { + throwOnError: true, + }) + expect.unreachable('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(HttpResponseError) + expect((e as HttpResponseError).response.status).toBe(500) + } + }) + }) + + describe('parseRetryAfter - additional edge cases', () => { + it('should reject partial numeric strings like "10abc"', () => { + // Strict parsing — "10abc" is not a valid delay-seconds value + expect(parseRetryAfter('10abc')).toBeUndefined() + }) + }) + + describe('sanitizeHeaders - additional edge cases', () => { + it('should handle __proto__ and constructor keys safely', () => { + const result = sanitizeHeaders({ + __proto__: 'poison', + constructor: 'attack', + 'x-normal': 'fine', + } as unknown as Record) + + // __proto__ is not enumerable via Object.keys on a normal object, + // but constructor is. + expect(result['x-normal']).toBe('fine') + expect(result['constructor']).toBe('attack') + // The output object should have null prototype. + expect(Object.getPrototypeOf(result)).toBeNull() + }) + + it('should skip inherited prototype properties', () => { + const proto = { inherited: 'should-not-appear' } + const obj = Object.create(proto) as Record + obj['own'] = 'visible' + + const result = sanitizeHeaders(obj) + expect(result['own']).toBe('visible') + expect('inherited' in result).toBe(false) + }) + }) + + describe('maxResponseSize - settle guard', () => { + it('should fire onResponse exactly once when maxResponseSize exceeded', async () => { + const responseInfos: Array< + import('@socketsecurity/lib/http-request').HttpHookResponseInfo + > = [] + + await httpRequest(`${httpBaseUrl}/large-body`, { + maxResponseSize: 50, + hooks: { + onResponse: info => responseInfos.push(info), + }, + }).catch(() => {}) + + // The settled guard prevents duplicate hook fires. + expect(responseInfos).toHaveLength(1) + expect(responseInfos[0]!.error).toBeDefined() + expect(responseInfos[0]!.error!.message).toMatch( + /exceeds maximum size limit/, + ) + }) + }) + + describe('parseRetryAfter - whitespace', () => { + it('should handle whitespace-padded integer', () => { + // parseInt trims leading whitespace + expect(parseRetryAfter(' 60 ')).toBe(60_000) + }) + }) + + describe('Uint8Array body', () => { + it('should send Uint8Array as request body (not treated as stream)', async () => { + const data = new Uint8Array([104, 101, 108, 108, 111]) // "hello" + const response = await httpRequest(`${httpBaseUrl}/echo-body`, { + method: 'POST', + body: Buffer.from(data) as Buffer, + }) + + expect(response.text()).toBe('hello') + }) + }) + + describe('onRetry - not called with retries: 0', () => { + it('should not call onRetry when retries is 0', async () => { + let onRetryCalled = false + + try { + await httpRequest(`${httpBaseUrl}/not-found`, { + throwOnError: true, + retries: 0, + onRetry: () => { + onRetryCalled = true + return undefined + }, + }) + } catch { + // Expected to throw + } + + expect(onRetryCalled).toBe(false) + }) + }) + + describe('redirect hook and cleanup', () => { + it('should fire onResponse exactly once per redirect hop on maxRedirects exceeded', async () => { + const responseInfos: Array< + import('@socketsecurity/lib/http-request').HttpHookResponseInfo + > = [] + + await httpRequest(`${httpBaseUrl}/redirect-loop-1`, { + maxRedirects: 2, + hooks: { + onResponse: info => responseInfos.push(info), + }, + }).catch(() => {}) + + // 3 redirect hops observed (each emits one 3xx hook before checking limits). + // The "too many redirects" rejection uses raw reject, not rejectOnce, + // so no additional error hook fires. Exactly 3 hook calls total. + expect(responseInfos).toHaveLength(3) + for (const info of responseInfos) { + expect(info.status).toBeGreaterThanOrEqual(300) + expect(info.status).toBeLessThan(400) + expect(info.error).toBeUndefined() + } + }) + + it('should work correctly with throwOnError across a 302 → 200 redirect', async () => { + const response = await httpRequest(`${httpBaseUrl}/redirect`, { + throwOnError: true, + }) + + expect(response.ok).toBe(true) + expect(response.status).toBe(200) + expect(response.text()).toBe('Plain text response') + }) + }) + + describe('stream body cleanup on failure', () => { + it('should destroy source stream body on request timeout', async () => { + const { Readable } = await import('node:stream') + let streamDestroyed = false + + // Create a slow stream that will outlive the request. + const slowStream = new Readable({ + read() { + // Never push data — simulate a stalled upload. + }, + destroy(_err, callback) { + streamDestroyed = true + callback(null) + }, + }) + + await expect( + httpRequest(`${httpBaseUrl}/timeout`, { + method: 'POST', + body: slowStream as import('node:stream').Readable, + timeout: 100, + }), + ).rejects.toThrow(/timed out/) + + expect(streamDestroyed).toBe(true) + }) + + it('should destroy source stream body on connection error', async () => { + const { Readable } = await import('node:stream') + let streamDestroyed = false + + const stream = new Readable({ + read() { + // Never push — connection will fail first. + }, + destroy(_err, callback) { + streamDestroyed = true + callback(null) + }, + }) + + await expect( + httpRequest('http://localhost:1/no-server', { + method: 'POST', + body: stream as import('node:stream').Readable, + timeout: 100, + }), + ).rejects.toThrow() + + expect(streamDestroyed).toBe(true) + }) + }) + + describe('hook error resilience', () => { + it('should still resolve when onResponse hook throws on success', async () => { + const response = await httpRequest(`${httpBaseUrl}/json`, { + hooks: { + onResponse: () => { + throw new Error('hook exploded') + }, + }, + }) + + // Promise must still settle despite the hook throwing. + expect(response.ok).toBe(true) + expect(response.status).toBe(200) + }) + + it('should still reject when onResponse hook throws on error', async () => { + await expect( + httpRequest(`${httpBaseUrl}/timeout`, { + timeout: 50, + hooks: { + onResponse: () => { + throw new Error('hook exploded on error') + }, + }, + }), + ).rejects.toThrow(/timed out/) + }) + + it('should still reject when onResponse hook throws on redirect failure', async () => { + await expect( + httpRequest(`${httpBaseUrl}/redirect-loop-1`, { + maxRedirects: 0, + hooks: { + onResponse: () => { + throw new Error('hook exploded on redirect') + }, + }, + }), + ).rejects.toThrow(/Too many redirects/) + }) + }) })