@applitools/req
Advanced tools
+856
| # @applitools/req | ||
| A powerful, flexible HTTP request library with advanced features like retry logic, hooks, fallbacks, and timeout management. | ||
| ## Table of Contents | ||
| - [Installation](#installation) | ||
| - [Basic Usage](#basic-usage) | ||
| - [API Reference](#api-reference) | ||
| - [req()](#req) | ||
| - [makeReq()](#makereq) | ||
| - [Options](#options) | ||
| - [Advanced Features](#advanced-features) | ||
| - [Retry Logic](#retry-logic) | ||
| - [Hooks](#hooks) | ||
| - [Fallbacks](#fallbacks) | ||
| - [Timeouts](#timeouts) | ||
| - [Chaining Options](#chaining-options) | ||
| - [Examples](#examples) | ||
| ## Installation | ||
| ```bash | ||
| yarn add @applitools/req | ||
| ``` | ||
| ## Basic Usage | ||
| ```typescript | ||
| import {req} from '@applitools/req' | ||
| // Simple GET request | ||
| const response = await req('https://api.example.com/data') | ||
| const data = await response.json() | ||
| ``` | ||
| ## API Reference | ||
| ### req() | ||
| The main function for making HTTP requests. | ||
| **Signature:** | ||
| ```typescript | ||
| req(input: string | URL | Request, ...options: Options[]): Promise<Response> | ||
| ``` | ||
| **Parameters:** | ||
| - `input` - URL string, URL object, or Request object | ||
| - `options` - One or more option objects that will be merged (optional) | ||
| **Returns:** Promise that resolves to a Response object | ||
| ### makeReq() | ||
| Creates a req function with predefined base options. | ||
| **Signature:** | ||
| ```typescript | ||
| makeReq<TOptions>(baseOptions: Partial<TOptions>): Req<TOptions> | ||
| ``` | ||
| **Example:** | ||
| ```typescript | ||
| const apiReq = makeReq({ | ||
| baseUrl: 'https://api.example.com', | ||
| headers: {'Authorization': 'Bearer token123'} | ||
| }) | ||
| // All requests will use the base options | ||
| const response = await apiReq('/users') | ||
| ``` | ||
| ### Options | ||
| All available options for configuring requests: | ||
| #### `baseUrl` | ||
| **Type:** `string` | ||
| Base URL for relative paths. Automatically adds trailing slash if missing. | ||
| **Example:** | ||
| ```typescript | ||
| await req('./users', {baseUrl: 'https://api.example.com/v1'}) | ||
| // Makes request to: https://api.example.com/v1/users | ||
| ``` | ||
| #### `method` | ||
| **Type:** `string` | ||
| HTTP method (uppercase). Overrides method from Request object. | ||
| **Example:** | ||
| ```typescript | ||
| await req('https://api.example.com/users', {method: 'POST'}) | ||
| ``` | ||
| #### `query` | ||
| **Type:** `Record<string, string | boolean | number | undefined>` | ||
| Query parameters to append to URL. Merges with existing query params. Undefined values are ignored. | ||
| **Example:** | ||
| ```typescript | ||
| await req('https://api.example.com/search?page=1', { | ||
| query: { | ||
| limit: 10, | ||
| filter: 'active', | ||
| skip: undefined // This won't be added | ||
| } | ||
| }) | ||
| // URL: https://api.example.com/search?page=1&limit=10&filter=active | ||
| ``` | ||
| #### `headers` | ||
| **Type:** `Record<string, string | string[] | undefined>` | ||
| HTTP headers. Merges with headers from Request object. Undefined values are filtered out. | ||
| **Example:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| headers: { | ||
| 'Authorization': 'Bearer token', | ||
| 'Content-Type': 'application/json', | ||
| 'X-Optional': undefined // Won't be sent | ||
| } | ||
| }) | ||
| ``` | ||
| #### `body` | ||
| **Type:** `NodeJS.ReadableStream | ArrayBufferView | string | Record<string, any> | any[] | null` | ||
| Request body. Plain objects and arrays are automatically serialized to JSON with appropriate content-type header. | ||
| **Example:** | ||
| ```typescript | ||
| // Automatic JSON serialization | ||
| await req('https://api.example.com/users', { | ||
| method: 'POST', | ||
| body: {name: 'John', age: 30} | ||
| }) | ||
| // Binary data | ||
| await req('https://api.example.com/upload', { | ||
| method: 'POST', | ||
| body: Buffer.from('binary data') | ||
| }) | ||
| ``` | ||
| #### `proxy` | ||
| **Type:** `Proxy | ((url: URL) => Proxy | undefined)` | ||
| Proxy configuration. Can be an object or function that returns proxy settings based on URL. | ||
| **Example:** | ||
| ```typescript | ||
| // Static proxy | ||
| await req('https://api.example.com/data', { | ||
| proxy: { | ||
| url: 'http://proxy.company.com:8080', | ||
| username: 'user', | ||
| password: 'pass' | ||
| } | ||
| }) | ||
| // Dynamic proxy | ||
| await req('https://api.example.com/data', { | ||
| proxy: (url) => { | ||
| if (url.hostname.includes('internal')) { | ||
| return {url: 'http://internal-proxy:8080'} | ||
| } | ||
| } | ||
| }) | ||
| ``` | ||
| #### `useDnsCache` | ||
| **Type:** `boolean` | ||
| Enable DNS caching for improved performance. | ||
| **Example:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', {useDnsCache: true}) | ||
| ``` | ||
| #### `connectionTimeout` | ||
| **Type:** `number` | ||
| Total timeout in milliseconds for the entire connection including all retries. Once exceeded, throws `ConnectionTimeoutError`. | ||
| **Example:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| connectionTimeout: 30000, // 30 seconds total | ||
| retry: {statuses: [500]} | ||
| }) | ||
| ``` | ||
| #### `requestTimeout` | ||
| **Type:** `number | {base: number; perByte: number}` | ||
| Timeout for a single request in milliseconds. Can be dynamic based on request body size. | ||
| **Example:** | ||
| ```typescript | ||
| // Fixed timeout | ||
| await req('https://api.example.com/data', { | ||
| requestTimeout: 5000 // 5 seconds per request | ||
| }) | ||
| // Dynamic timeout based on body size | ||
| await req('https://api.example.com/upload', { | ||
| method: 'POST', | ||
| body: largeBuffer, | ||
| requestTimeout: { | ||
| base: 1000, // 1 second base | ||
| perByte: 0.001 // + 1ms per byte | ||
| } | ||
| }) | ||
| ``` | ||
| #### `retryTimeout` | ||
| **Type:** `number` | ||
| Maximum duration in milliseconds for all retry attempts across all retry strategies. Once exceeded, throws `RetryTimeoutError`. | ||
| **Example:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| retryTimeout: 30000, // Stop all retries after 30 seconds total | ||
| retry: [ | ||
| {statuses: [500], timeout: 1000}, | ||
| {codes: ['ECONNRESET'], timeout: 500} | ||
| ] | ||
| }) | ||
| ``` | ||
| #### `retry` | ||
| **Type:** `Retry | Retry[]` | ||
| Retry configuration(s). Multiple retry configs can be provided as array. | ||
| See [Retry Logic](#retry-logic) for detailed examples. | ||
| #### `hooks` | ||
| **Type:** `Hooks | Hooks[]` | ||
| Lifecycle hooks for request interception and modification. | ||
| See [Hooks](#hooks) for detailed examples. | ||
| #### `fallbacks` | ||
| **Type:** `Fallback | Fallback[]` | ||
| Fallback strategies for handling failures. | ||
| See [Fallbacks](#fallbacks) for detailed examples. | ||
| #### `keepAliveOptions` | ||
| **Type:** `{keepAlive: boolean; keepAliveMsecs?: number}` | ||
| HTTP agent keep-alive configuration. | ||
| **Example:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| keepAliveOptions: { | ||
| keepAlive: true, | ||
| keepAliveMsecs: 1000 | ||
| } | ||
| }) | ||
| ``` | ||
| #### `signal` | ||
| **Type:** `AbortSignal` | ||
| Abort signal for canceling requests. | ||
| **Example:** | ||
| ```typescript | ||
| const controller = new AbortController() | ||
| // Cancel after 5 seconds | ||
| setTimeout(() => controller.abort(), 5000) | ||
| await req('https://api.example.com/data', { | ||
| signal: controller.signal | ||
| }) | ||
| ``` | ||
| ## Advanced Features | ||
| ### Retry Logic | ||
| The `retry` option enables automatic retrying of failed requests based on various conditions. | ||
| #### Retry Configuration | ||
| ```typescript | ||
| interface Retry { | ||
| limit?: number // Max retry attempts (default: unlimited if undefined) | ||
| timeout?: number | number[] // Delay between retries in ms (default: 0 - no delay) | ||
| statuses?: number[] // HTTP status codes to retry (default: none) | ||
| codes?: string[] // Error codes to retry (default: none) | ||
| validate?: (options) => boolean // Custom validation function (default: undefined) | ||
| } | ||
| ``` | ||
| **Default Behavior:** | ||
| - **`limit`**: If undefined or not set, retries will continue indefinitely (use with caution - combine with `connectionTimeout` or `retryTimeout`) | ||
| - **`timeout`**: If undefined, retries happen immediately with no delay (0ms) | ||
| - **`statuses`**: If undefined, no status codes trigger retries (must be explicitly configured) | ||
| - **`codes`**: If undefined, no error codes trigger retries (must be explicitly configured) | ||
| - **`validate`**: If undefined, only `statuses` and `codes` are checked | ||
| - **Retry trigger**: At least one of `statuses`, `codes`, or `validate` must match for a retry to occur | ||
| - **`Retry-After` header**: If present in the response, overrides the configured `timeout` value | ||
| #### Examples | ||
| **Retry on specific status codes:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| retry: { | ||
| statuses: [500, 502, 503], // Retry on server errors | ||
| limit: 3, // Max 3 attempts | ||
| timeout: 1000 // Wait 1 second between retries | ||
| } | ||
| }) | ||
| ``` | ||
| **Retry on network errors:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| retry: { | ||
| codes: ['ECONNRESET', 'ETIMEDOUT'], | ||
| limit: 5, | ||
| timeout: 2000 | ||
| } | ||
| }) | ||
| ``` | ||
| **Exponential backoff:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| retry: { | ||
| statuses: [429, 500], | ||
| limit: 4, | ||
| timeout: [1000, 2000, 4000, 8000] // Double delay each time | ||
| } | ||
| }) | ||
| ``` | ||
| **Custom validation:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| retry: { | ||
| validate: async ({response, error}) => { | ||
| if (error) return true | ||
| if (response?.status === 429) { | ||
| // Check rate limit header | ||
| return response.headers.has('Retry-After') | ||
| } | ||
| return false | ||
| }, | ||
| limit: 3 | ||
| } | ||
| }) | ||
| ``` | ||
| **Multiple retry strategies:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| retry: [ | ||
| // Retry network errors quickly | ||
| {codes: ['ECONNRESET'], limit: 3, timeout: 500}, | ||
| // Retry server errors with longer delay | ||
| {statuses: [500, 503], limit: 2, timeout: 2000} | ||
| ] | ||
| }) | ||
| ``` | ||
| **Retry with timeout limit:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| retryTimeout: 10000, // Stop all retries after 10 seconds | ||
| retry: [ | ||
| {codes: ['ECONNRESET'], timeout: 500}, | ||
| {statuses: [500, 503], timeout: 1000} | ||
| ] | ||
| }) | ||
| // If retries take longer than 10 seconds total, RetryTimeoutError is thrown | ||
| ``` | ||
| ### Hooks | ||
| Hooks allow you to intercept and modify requests/responses at various lifecycle stages. | ||
| #### Available Hooks | ||
| ```typescript | ||
| interface Hooks { | ||
| afterOptionsMerged?(options): TOptions | void | ||
| beforeRequest?(options): Request | void | ||
| beforeRetry?(options): Request | Stop | void | ||
| afterResponse?(options): Response | void | ||
| afterError?(options): Error | void | ||
| unknownBodyType?(options): void | ||
| } | ||
| ``` | ||
| #### Examples | ||
| **Add authentication header:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| hooks: { | ||
| beforeRequest: ({request}) => { | ||
| request.headers.set('Authorization', `Bearer ${getToken()}`) | ||
| } | ||
| } | ||
| }) | ||
| ``` | ||
| **Log all requests:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| hooks: { | ||
| beforeRequest: ({request}) => { | ||
| console.log(`${request.method} ${request.url}`) | ||
| }, | ||
| afterResponse: ({response}) => { | ||
| console.log(`Response: ${response.status}`) | ||
| } | ||
| } | ||
| }) | ||
| ``` | ||
| **Conditional retry prevention:** | ||
| ```typescript | ||
| import {stop} from '@applitools/req' | ||
| await req('https://api.example.com/data', { | ||
| retry: {statuses: [500]}, | ||
| hooks: { | ||
| beforeRetry: async ({response, attempt, stop}) => { | ||
| const data = await response?.json() | ||
| if (data?.error === 'FATAL') { | ||
| console.log('Fatal error, stopping retries') | ||
| return stop | ||
| } | ||
| console.log(`Retry attempt ${attempt}`) | ||
| } | ||
| } | ||
| }) | ||
| ``` | ||
| **Transform response:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| hooks: { | ||
| afterResponse: async ({response}) => { | ||
| if (!response.ok) { | ||
| const error = await response.text() | ||
| throw new Error(`API Error: ${error}`) | ||
| } | ||
| } | ||
| } | ||
| }) | ||
| ``` | ||
| **Modify request before retry:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| retry: {statuses: [401]}, | ||
| hooks: { | ||
| beforeRetry: async ({request, attempt}) => { | ||
| // Refresh token on 401 | ||
| const newToken = await refreshAuthToken() | ||
| request.headers.set('Authorization', `Bearer ${newToken}`) | ||
| return request | ||
| } | ||
| } | ||
| }) | ||
| ``` | ||
| **Multiple hooks:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| hooks: [ | ||
| { | ||
| beforeRequest: ({request}) => { | ||
| request.headers.set('X-Request-ID', generateId()) | ||
| } | ||
| }, | ||
| { | ||
| beforeRequest: ({request}) => { | ||
| request.headers.set('X-Timestamp', Date.now().toString()) | ||
| } | ||
| } | ||
| ] | ||
| }) | ||
| ``` | ||
| ### Fallbacks | ||
| Fallbacks provide alternative strategies when requests fail. | ||
| #### Fallback Configuration | ||
| ```typescript | ||
| interface Fallback { | ||
| shouldFallbackCondition: (options) => boolean | Promise<boolean> | ||
| updateOptions?: (options) => Options | Promise<Options> | ||
| cache?: Map<string, boolean> | ||
| } | ||
| ``` | ||
| #### Examples | ||
| **Fallback to different endpoint:** | ||
| ```typescript | ||
| await req('https://api-primary.example.com/data', { | ||
| fallbacks: { | ||
| shouldFallbackCondition: ({response}) => response.status >= 500, | ||
| updateOptions: ({options}) => ({ | ||
| ...options, | ||
| baseUrl: 'https://api-backup.example.com' | ||
| }) | ||
| } | ||
| }) | ||
| ``` | ||
| **Try with authentication if unauthorized:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| fallbacks: { | ||
| shouldFallbackCondition: ({response}) => response.status === 401, | ||
| updateOptions: async ({options}) => ({ | ||
| ...options, | ||
| headers: { | ||
| ...options.headers, | ||
| 'Authorization': `Bearer ${await getToken()}` | ||
| } | ||
| }) | ||
| } | ||
| }) | ||
| ``` | ||
| **Multiple fallback strategies:** | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| fallbacks: [ | ||
| // First try: enable keep-alive | ||
| { | ||
| shouldFallbackCondition: ({response}) => response.status === 503, | ||
| updateOptions: ({options}) => ({ | ||
| ...options, | ||
| keepAliveOptions: {keepAlive: true} | ||
| }) | ||
| }, | ||
| // Second try: use proxy | ||
| { | ||
| shouldFallbackCondition: ({response}) => response.status === 403, | ||
| updateOptions: ({options}) => ({ | ||
| ...options, | ||
| proxy: {url: 'http://proxy.example.com:8080'} | ||
| }) | ||
| } | ||
| ] | ||
| }) | ||
| ``` | ||
| ### Timeouts | ||
| Three types of timeouts control request timing: | ||
| #### `connectionTimeout` | ||
| Total timeout across all retries and delays. Throws `ConnectionTimeoutError` when exceeded. | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| connectionTimeout: 30000, // 30 seconds total for all attempts | ||
| retry: { | ||
| statuses: [500], | ||
| limit: 5, | ||
| timeout: 2000 | ||
| } | ||
| }) | ||
| // If all 5 retries take too long, ConnectionTimeoutError is thrown | ||
| ``` | ||
| #### `requestTimeout` | ||
| Timeout for each individual request attempt. Throws `RequestTimeoutError` when exceeded. | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| requestTimeout: 5000, // Each attempt times out after 5 seconds | ||
| retry: { | ||
| codes: ['RequestTimeout'], | ||
| limit: 3 | ||
| } | ||
| }) | ||
| ``` | ||
| #### `retryTimeout` | ||
| Total timeout for all retry attempts across all retry strategies. Throws `RetryTimeoutError` when exceeded. | ||
| ```typescript | ||
| await req('https://api.example.com/data', { | ||
| retryTimeout: 15000, // Stop retrying after 15 seconds total | ||
| retry: [ | ||
| {codes: ['ECONNRESET'], timeout: 1000}, | ||
| {statuses: [500, 503], timeout: 2000} | ||
| ] | ||
| }) | ||
| // If retry process takes longer than 15 seconds, RetryTimeoutError is thrown | ||
| ``` | ||
| **Difference between timeouts:** | ||
| - `connectionTimeout`: Covers the entire request lifecycle including initial attempt and all retries | ||
| - `retryTimeout`: Only covers retry attempts (excludes the initial request) | ||
| - `requestTimeout`: Applies to each individual request attempt | ||
| #### Dynamic request timeout based on body size: | ||
| ```typescript | ||
| await req('https://api.example.com/upload', { | ||
| method: 'POST', | ||
| body: fileBuffer, | ||
| requestTimeout: { | ||
| base: 5000, // 5 seconds baseline | ||
| perByte: 0.01 // + 10ms per byte | ||
| } | ||
| }) | ||
| // For 1MB file: timeout = 5000 + (1048576 * 0.01) ≈ 15.5 seconds | ||
| ``` | ||
| ## Chaining Options | ||
| Multiple option objects can be passed and will be deeply merged. This enables powerful composition patterns. | ||
| ### Merge Behavior | ||
| - Simple properties (strings, numbers) are overridden | ||
| - Objects (`query`, `headers`) are merged | ||
| - Arrays (`retry`, `hooks`, `fallbacks`) are concatenated | ||
| - Later options take precedence | ||
| ### Examples | ||
| **Basic chaining:** | ||
| ```typescript | ||
| const baseOptions = { | ||
| baseUrl: 'https://api.example.com', | ||
| headers: {'User-Agent': 'MyApp/1.0'} | ||
| } | ||
| const authOptions = { | ||
| headers: {'Authorization': 'Bearer token'} | ||
| } | ||
| // Merged result: | ||
| // - baseUrl: 'https://api.example.com' | ||
| // - headers: {'User-Agent': 'MyApp/1.0', 'Authorization': 'Bearer token'} | ||
| await req('/users', baseOptions, authOptions) | ||
| ``` | ||
| **Override with precedence:** | ||
| ```typescript | ||
| const options1 = { | ||
| requestTimeout: 5000, | ||
| headers: {'X-Version': '1'} | ||
| } | ||
| const options2 = { | ||
| requestTimeout: 10000, | ||
| headers: {'X-Version': '2', 'X-New': 'value'} | ||
| } | ||
| // Result: | ||
| // - requestTimeout: 10000 (overridden) | ||
| // - headers: {'X-Version': '2', 'X-New': 'value'} (merged) | ||
| await req('https://api.example.com/data', options1, options2) | ||
| ``` | ||
| **Combining retry strategies:** | ||
| ```typescript | ||
| const networkRetry = { | ||
| retry: {codes: ['ECONNRESET'], limit: 3, timeout: 500} | ||
| } | ||
| const serverRetry = { | ||
| retry: {statuses: [500, 503], limit: 2, timeout: 2000} | ||
| } | ||
| // Both retry strategies will be active | ||
| await req('https://api.example.com/data', networkRetry, serverRetry) | ||
| ``` | ||
| **Accumulating hooks:** | ||
| ```typescript | ||
| const loggingHooks = { | ||
| hooks: { | ||
| beforeRequest: ({request}) => console.log('Request:', request.url) | ||
| } | ||
| } | ||
| const authHooks = { | ||
| hooks: { | ||
| beforeRequest: ({request}) => request.headers.set('Auth', 'token') | ||
| } | ||
| } | ||
| // Both hooks execute in order | ||
| await req('https://api.example.com/data', loggingHooks, authHooks) | ||
| ``` | ||
| **Practical composition pattern:** | ||
| ```typescript | ||
| // Define reusable option sets | ||
| const apiDefaults = { | ||
| baseUrl: 'https://api.example.com', | ||
| connectionTimeout: 30000, | ||
| retry: {codes: ['ECONNRESET'], limit: 3} | ||
| } | ||
| const withAuth = { | ||
| headers: {'Authorization': `Bearer ${token}`} | ||
| } | ||
| const withRetry = { | ||
| retry: {statuses: [500, 502, 503], limit: 5, timeout: [1000, 2000, 4000]} | ||
| } | ||
| const withLogging = { | ||
| hooks: { | ||
| beforeRequest: ({request}) => logger.info('Request', request.url), | ||
| afterResponse: ({response}) => logger.info('Response', response.status) | ||
| } | ||
| } | ||
| // Compose as needed | ||
| await req('/users', apiDefaults, withAuth) | ||
| await req('/critical-data', apiDefaults, withAuth, withRetry, withLogging) | ||
| ``` | ||
| **Using makeReq for composition:** | ||
| ```typescript | ||
| // Create base client | ||
| const apiClient = makeReq({ | ||
| baseUrl: 'https://api.example.com', | ||
| headers: {'User-Agent': 'MyApp/1.0'}, | ||
| retry: {codes: ['ECONNRESET']} | ||
| }) | ||
| // Add authentication per request | ||
| await apiClient('/public-data') | ||
| await apiClient('/private-data', { | ||
| headers: {'Authorization': 'Bearer token'} | ||
| }) | ||
| // Create specialized client with additional options | ||
| const authedClient = makeReq({ | ||
| baseUrl: 'https://api.example.com', | ||
| headers: { | ||
| 'User-Agent': 'MyApp/1.0', | ||
| 'Authorization': 'Bearer token' | ||
| } | ||
| }) | ||
| await authedClient('/user/profile') | ||
| ``` | ||
| ## Examples | ||
| ### Complete real-world example | ||
| ```typescript | ||
| import {req, makeReq, stop} from '@applitools/req' | ||
| // Create API client with defaults | ||
| const apiClient = makeReq({ | ||
| baseUrl: 'https://api.example.com/v1', | ||
| connectionTimeout: 60000, | ||
| requestTimeout: 10000, | ||
| headers: { | ||
| 'User-Agent': 'MyApp/2.0', | ||
| 'Accept': 'application/json' | ||
| }, | ||
| retry: [ | ||
| // Quick retry for network errors | ||
| { | ||
| codes: ['ECONNRESET', 'ETIMEDOUT'], | ||
| limit: 3, | ||
| timeout: 500 | ||
| }, | ||
| // Slower retry for server errors | ||
| { | ||
| statuses: [500, 502, 503], | ||
| limit: 5, | ||
| timeout: [1000, 2000, 4000, 8000] | ||
| } | ||
| ], | ||
| hooks: { | ||
| beforeRequest: ({request}) => { | ||
| console.log(`→ ${request.method} ${request.url}`) | ||
| }, | ||
| afterResponse: ({response}) => { | ||
| console.log(`← ${response.status} ${response.statusText}`) | ||
| }, | ||
| afterError: ({error}) => { | ||
| console.error('Request failed:', error.message) | ||
| } | ||
| } | ||
| }) | ||
| // Authenticated requests | ||
| const authedClient = makeReq({ | ||
| baseUrl: 'https://api.example.com/v1', | ||
| headers: {'Authorization': `Bearer ${getToken()}`}, | ||
| retry: { | ||
| statuses: [401], | ||
| limit: 1 | ||
| }, | ||
| hooks: { | ||
| beforeRetry: async ({request, response, stop}) => { | ||
| if (response?.status === 401) { | ||
| try { | ||
| const newToken = await refreshToken() | ||
| request.headers.set('Authorization', `Bearer ${newToken}`) | ||
| return request | ||
| } catch { | ||
| return stop | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }) | ||
| // Usage | ||
| const users = await apiClient('/users', { | ||
| query: {page: 1, limit: 20} | ||
| }) | ||
| const profile = await authedClient('/user/profile') | ||
| const result = await authedClient('/user/update', { | ||
| method: 'POST', | ||
| body: {name: 'John Doe', email: 'john@example.com'} | ||
| }) | ||
| ``` | ||
| ## License | ||
| MIT |
+14
-0
| # Changelog | ||
| ## [1.8.6](https://github.com/Applitools-Dev/sdk/compare/js/req@1.8.5...js/req@1.8.6) (2025-11-19) | ||
| ### Dependencies | ||
| * @applitools/utils bumped to 1.14.0 | ||
| #### Features | ||
| * disable heartbeats whenever no tests are running ([#3344](https://github.com/Applitools-Dev/sdk/issues/3344)) ([b66d28a](https://github.com/Applitools-Dev/sdk/commit/b66d28a7a382f26b68de70c8633c027cb4bdf225)) | ||
| * @applitools/logger bumped to 2.2.6 | ||
| * @applitools/test-server bumped to 1.3.5 | ||
| ## [1.8.5](https://github.com/Applitools-Dev/sdk/compare/js/req@1.8.4...js/req@1.8.5) (2025-11-09) | ||
@@ -4,0 +18,0 @@ |
+1
-0
@@ -5,1 +5,2 @@ export { stop } from './stop.js'; | ||
| export { req as default } from './req.js'; | ||
| export { RequestTimeoutError, ConnectionTimeoutError, RetryTimeoutError } from './req-errors.js'; |
@@ -5,2 +5,3 @@ export var AbortCode; | ||
| AbortCode["connectionTimeout"] = "MAX_TIMEOUT_REACHED"; | ||
| AbortCode["retryTimeout"] = "RETRY_DURATION_EXCEEDED"; | ||
| })(AbortCode || (AbortCode = {})); | ||
@@ -21,1 +22,8 @@ export class RequestTimeoutError extends Error { | ||
| } | ||
| export class RetryTimeoutError extends Error { | ||
| constructor(timeout) { | ||
| super(`Retry duration of ${timeout} ms was exceeded.`); | ||
| this.code = AbortCode.retryTimeout; | ||
| this.name = 'RetryTimeoutError'; | ||
| } | ||
| } |
+161
-92
| import { AbortController } from 'abort-controller'; | ||
| import { stop } from './stop.js'; | ||
| import { makeAgent } from './agent.js'; | ||
| import { AbortCode, RequestTimeoutError, ConnectionTimeoutError } from './req-errors.js'; | ||
| import { AbortCode, RequestTimeoutError, ConnectionTimeoutError, RetryTimeoutError } from './req-errors.js'; | ||
| import globalFetch, { Request, Headers, Response } from './fetch.js'; | ||
@@ -19,2 +19,3 @@ import * as utils from '@applitools/utils'; | ||
| let abortCode; | ||
| let retryStartTime = null; | ||
| if (options.baseUrl && !options.baseUrl.endsWith('/')) | ||
@@ -50,14 +51,5 @@ options.baseUrl += '/'; | ||
| } | ||
| async function singleReq(input, options) { | ||
| var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p; | ||
| const url = new URL(String((_a = input.url) !== null && _a !== void 0 ? _a : input), options.baseUrl); | ||
| const fetch = (_b = options.fetch) !== null && _b !== void 0 ? _b : globalFetch; | ||
| let optionsFallbacks = []; | ||
| if (options.fallbacks) | ||
| optionsFallbacks = utils.types.isArray(options.fallbacks) ? options.fallbacks : [options.fallbacks]; | ||
| const fb = optionsFallbacks.find(fallback => { var _a; return (_a = fallback.cache) === null || _a === void 0 ? void 0 : _a.get(url.origin); }); | ||
| if (fb === null || fb === void 0 ? void 0 : fb.updateOptions) | ||
| options = await fb.updateOptions({ options }); | ||
| function setupRequestController(opts) { | ||
| const requestController = new AbortController(); | ||
| const timeout = calculateTimeout(options.requestTimeout, options.body, options); | ||
| const timeout = calculateTimeout(opts.requestTimeout, opts.body, opts); | ||
| const requestTimer = timeout | ||
@@ -69,12 +61,18 @@ ? setTimeout(() => { | ||
| : null; | ||
| const abortHandler = () => requestController.abort(); | ||
| if (connectionController.signal.aborted) | ||
| requestController.abort(); | ||
| connectionController.signal.onabort = () => requestController.abort(); | ||
| if (options.signal) { | ||
| if (options.signal.aborted) | ||
| connectionController.signal.addEventListener('abort', abortHandler); | ||
| if (opts.signal) { | ||
| if (opts.signal.aborted) | ||
| requestController.abort(); | ||
| options.signal.onabort = () => requestController.abort(); | ||
| opts.signal.addEventListener('abort', abortHandler); | ||
| } | ||
| if (options.query) { | ||
| Object.entries(options.query).forEach(([key, value]) => { | ||
| return { requestController, requestTimer, abortHandler }; | ||
| } | ||
| async function buildRequest(input, opts, requestController) { | ||
| var _a, _b, _c, _d, _e; | ||
| const url = new URL(String((_a = input.url) !== null && _a !== void 0 ? _a : input), opts.baseUrl); | ||
| if (opts.query) { | ||
| Object.entries(opts.query).forEach(([key, value]) => { | ||
| if (!utils.types.isNull(value)) | ||
@@ -85,76 +83,64 @@ url.searchParams.set(key, String(value)); | ||
| const extraHeaders = {}; | ||
| if (utils.types.isPlainObject(options.body) || utils.types.isArray(options.body) || options.body === null) { | ||
| options.body = JSON.stringify(options.body); | ||
| if (utils.types.isPlainObject(opts.body) || utils.types.isArray(opts.body) || opts.body === null) { | ||
| opts.body = JSON.stringify(opts.body); | ||
| extraHeaders['content-type'] = 'application/json'; | ||
| } | ||
| let request = new Request(url, { | ||
| method: (_c = options.method) !== null && _c !== void 0 ? _c : input.method, | ||
| method: (_b = opts.method) !== null && _b !== void 0 ? _b : input.method, | ||
| headers: { | ||
| ...extraHeaders, | ||
| ...Object.fromEntries((_e = (_d = input.headers) === null || _d === void 0 ? void 0 : _d.entries()) !== null && _e !== void 0 ? _e : []), | ||
| ...Object.fromEntries(new Headers(options.headers).entries()), | ||
| ...Object.fromEntries((_d = (_c = input.headers) === null || _c === void 0 ? void 0 : _c.entries()) !== null && _d !== void 0 ? _d : []), | ||
| ...Object.fromEntries(new Headers(opts.headers).entries()), | ||
| }, | ||
| body: (_f = options.body) !== null && _f !== void 0 ? _f : input.body, | ||
| body: (_e = opts.body) !== null && _e !== void 0 ? _e : input.body, | ||
| highWaterMark: 1024 * 1024 * 100 + 1, | ||
| agent: makeAgent({ | ||
| proxy: options.proxy, | ||
| useDnsCache: options.useDnsCache, | ||
| keepAliveOptions: options.keepAliveOptions, | ||
| proxy: opts.proxy, | ||
| useDnsCache: opts.useDnsCache, | ||
| keepAliveOptions: opts.keepAliveOptions, | ||
| }), | ||
| signal: requestController.signal, | ||
| }); | ||
| request = await beforeRequest({ request, options }); | ||
| try { | ||
| let response = await fetch(request); | ||
| // if the request has a fallback try it | ||
| if (!response.ok && optionsFallbacks.length > 0) { | ||
| const fallbackStrategy = optionsFallbacks[0]; | ||
| const shouldFallback = await fallbackStrategy.shouldFallbackCondition({ request, response }); | ||
| const fallbackOptions = shouldFallback && | ||
| (await ((_g = fallbackStrategy === null || fallbackStrategy === void 0 ? void 0 : fallbackStrategy.updateOptions) === null || _g === void 0 ? void 0 : _g.call(fallbackStrategy, { | ||
| options: { ...options, fallbacks: optionsFallbacks.slice(1) }, | ||
| }))); | ||
| if (fallbackOptions) { | ||
| const fallbackStrategyResponse = await singleReq(request, fallbackOptions); | ||
| (_h = fallbackStrategy.cache) !== null && _h !== void 0 ? _h : (fallbackStrategy.cache = new Map()); | ||
| fallbackStrategy.cache.set(new URL(request.url).origin, fallbackStrategyResponse.ok); | ||
| return fallbackStrategyResponse; | ||
| } | ||
| } | ||
| // if the request has to be retried due to status code | ||
| const retry = await ((_j = options.retry) === null || _j === void 0 ? void 0 : _j.reduce(async (prev, retry) => { | ||
| request = await beforeRequest({ request, options: opts }); | ||
| return request; | ||
| } | ||
| async function tryFallback(request, response, opts, optionsFallbacks) { | ||
| var _a, _b; | ||
| if (response.ok || optionsFallbacks.length === 0) | ||
| return null; | ||
| const fallbackStrategy = optionsFallbacks[0]; | ||
| const shouldFallback = await fallbackStrategy.shouldFallbackCondition({ request, response }); | ||
| const fallbackOptions = shouldFallback && | ||
| (await ((_a = fallbackStrategy === null || fallbackStrategy === void 0 ? void 0 : fallbackStrategy.updateOptions) === null || _a === void 0 ? void 0 : _a.call(fallbackStrategy, { | ||
| options: { ...opts, fallbacks: optionsFallbacks.slice(1) }, | ||
| }))); | ||
| if (fallbackOptions) { | ||
| const fallbackStrategyResponse = await singleReq(request, fallbackOptions); | ||
| (_b = fallbackStrategy.cache) !== null && _b !== void 0 ? _b : (fallbackStrategy.cache = new Map()); | ||
| fallbackStrategy.cache.set(new URL(request.url).origin, fallbackStrategyResponse.ok); | ||
| return fallbackStrategyResponse; | ||
| } | ||
| return null; | ||
| } | ||
| async function findApplicableRetry(opts, context) { | ||
| const retries = opts.retry; | ||
| if (!retries) | ||
| return null; | ||
| if (context.response) { | ||
| return await retries.reduce(async (prev, retry) => { | ||
| var _a, _b; | ||
| const result = await prev; | ||
| return (result !== null && result !== void 0 ? result : ((((_a = retry.statuses) === null || _a === void 0 ? void 0 : _a.includes(response.status)) || (await ((_b = retry.validate) === null || _b === void 0 ? void 0 : _b.call(retry, { response })))) && | ||
| return (result !== null && result !== void 0 ? result : ((((_a = retry.statuses) === null || _a === void 0 ? void 0 : _a.includes(context.response.status)) || | ||
| (await ((_b = retry.validate) === null || _b === void 0 ? void 0 : _b.call(retry, { response: context.response })))) && | ||
| (!retry.limit || !retry.attempt || retry.attempt < retry.limit) | ||
| ? retry | ||
| : null)); | ||
| }, Promise.resolve(null))); | ||
| if (retry) { | ||
| (_k = retry.attempt) !== null && _k !== void 0 ? _k : (retry.attempt = 0); | ||
| const delay = response.headers.has('Retry-After') | ||
| ? Number(response.headers.get('Retry-After')) * 1000 | ||
| : utils.types.isArray(retry.timeout) | ||
| ? retry.timeout[Math.min(retry.attempt, retry.timeout.length - 1)] | ||
| : (_l = retry.timeout) !== null && _l !== void 0 ? _l : 0; | ||
| await utils.general.sleep(delay); | ||
| retry.attempt += 1; | ||
| const retryRequest = await beforeRetry({ request, response, attempt: retry.attempt, stop, options }); | ||
| if (retryRequest !== stop) { | ||
| return singleReq(retryRequest, options); | ||
| } | ||
| } | ||
| response = await afterResponse({ request, response, options }); | ||
| return response; | ||
| }, Promise.resolve(null)); | ||
| } | ||
| catch (error) { | ||
| if (abortCode === AbortCode.requestTimeout) | ||
| error = new RequestTimeoutError(); | ||
| else if (abortCode === AbortCode.connectionTimeout) | ||
| error = new ConnectionTimeoutError(); | ||
| // if the request has to be retried due to network error | ||
| const retry = await ((_m = options.retry) === null || _m === void 0 ? void 0 : _m.reduce((prev, retry) => { | ||
| if (context.error) { | ||
| return await retries.reduce((prev, retry) => { | ||
| return prev.then(async (result) => { | ||
| var _a, _b; | ||
| return (result !== null && result !== void 0 ? result : ((((_a = retry.codes) === null || _a === void 0 ? void 0 : _a.includes(error.code)) || (await ((_b = retry.validate) === null || _b === void 0 ? void 0 : _b.call(retry, { error })))) && | ||
| return (result !== null && result !== void 0 ? result : ((((_a = retry.codes) === null || _a === void 0 ? void 0 : _a.includes(context.error.code)) || | ||
| (await ((_b = retry.validate) === null || _b === void 0 ? void 0 : _b.call(retry, { error: context.error })))) && | ||
| (!retry.limit || !retry.attempt || retry.attempt < retry.limit))) | ||
@@ -164,24 +150,107 @@ ? retry | ||
| }); | ||
| }, Promise.resolve(null))); | ||
| if (retry) { | ||
| (_o = retry.attempt) !== null && _o !== void 0 ? _o : (retry.attempt = 0); | ||
| const delay = utils.types.isArray(retry.timeout) | ||
| ? retry.timeout[Math.min(retry.attempt, retry.timeout.length)] | ||
| : (_p = retry.timeout) !== null && _p !== void 0 ? _p : 0; | ||
| await utils.general.sleep(delay); | ||
| retry.attempt = retry.attempt + 1; | ||
| const retryRequest = await beforeRetry({ request, error, attempt: retry.attempt, stop, options }); | ||
| if (retryRequest !== stop) { | ||
| return singleReq(retryRequest, options); | ||
| }, Promise.resolve(null)); | ||
| } | ||
| return null; | ||
| } | ||
| function calculateRetryDelay(retry, response) { | ||
| var _a, _b; | ||
| if (response === null || response === void 0 ? void 0 : response.headers.has('Retry-After')) { | ||
| return Number(response.headers.get('Retry-After')) * 1000; | ||
| } | ||
| if (utils.types.isArray(retry.timeout)) { | ||
| return retry.timeout[Math.min((_a = retry.attempt) !== null && _a !== void 0 ? _a : 0, retry.timeout.length - 1)]; | ||
| } | ||
| return (_b = retry.timeout) !== null && _b !== void 0 ? _b : 0; | ||
| } | ||
| function checkRetryTimeout() { | ||
| if (options.retryTimeout && retryStartTime && Date.now() - retryStartTime >= options.retryTimeout) { | ||
| throw new RetryTimeoutError(options.retryTimeout); | ||
| } | ||
| } | ||
| async function handleRetry(request, retry, context, opts) { | ||
| var _a; | ||
| (_a = retry.attempt) !== null && _a !== void 0 ? _a : (retry.attempt = 0); | ||
| retryStartTime !== null && retryStartTime !== void 0 ? retryStartTime : (retryStartTime = Date.now()); | ||
| checkRetryTimeout(); | ||
| const delay = calculateRetryDelay(retry, context.response); | ||
| await utils.general.sleep(delay); | ||
| retry.attempt += 1; | ||
| return await beforeRetry({ | ||
| request, | ||
| ...context, | ||
| attempt: retry.attempt, | ||
| stop, | ||
| options: opts, | ||
| }); | ||
| } | ||
| function normalizeAbortError(error) { | ||
| if (abortCode === AbortCode.requestTimeout) | ||
| return new RequestTimeoutError(); | ||
| if (abortCode === AbortCode.connectionTimeout) | ||
| return new ConnectionTimeoutError(); | ||
| return error; | ||
| } | ||
| function cleanupRequest(opts, requestTimer, abortHandler) { | ||
| if (requestTimer) | ||
| clearTimeout(requestTimer); | ||
| // Remove the abort listeners we attached | ||
| connectionController.signal.removeEventListener('abort', abortHandler); | ||
| if (opts.signal) { | ||
| opts.signal.removeEventListener('abort', abortHandler); | ||
| } | ||
| } | ||
| async function singleReq(input, options) { | ||
| var _a, _b; | ||
| const fetch = (_a = options.fetch) !== null && _a !== void 0 ? _a : globalFetch; | ||
| let optionsFallbacks = []; | ||
| if (options.fallbacks) | ||
| optionsFallbacks = utils.types.isArray(options.fallbacks) ? options.fallbacks : [options.fallbacks]; | ||
| // Apply cached fallback options if available | ||
| const url = new URL(String((_b = input.url) !== null && _b !== void 0 ? _b : input), options.baseUrl); | ||
| const fb = optionsFallbacks.find(fallback => { var _a; return (_a = fallback.cache) === null || _a === void 0 ? void 0 : _a.get(url.origin); }); | ||
| if (fb === null || fb === void 0 ? void 0 : fb.updateOptions) | ||
| options = await fb.updateOptions({ options }); | ||
| while (true) { | ||
| const { requestController, requestTimer, abortHandler } = setupRequestController(options); | ||
| const request = await buildRequest(input, options, requestController); | ||
| try { | ||
| let response = await fetch(request); | ||
| // Try fallback if needed | ||
| const fallbackResponse = await tryFallback(request, response, options, optionsFallbacks); | ||
| if (fallbackResponse) | ||
| return fallbackResponse; | ||
| // Check if retry is needed for status code | ||
| const retry = await findApplicableRetry(options, { response }); | ||
| if (retry) { | ||
| const retryRequest = await handleRetry(request, retry, { response }, options); | ||
| if (retryRequest !== stop) { | ||
| cleanupRequest(options, requestTimer, abortHandler); | ||
| input = retryRequest; | ||
| continue; | ||
| } | ||
| } | ||
| // Success - return response | ||
| response = await afterResponse({ request, response, options }); | ||
| return response; | ||
| } | ||
| error = await afterError({ request, error, options }); | ||
| throw error; | ||
| catch (error) { | ||
| error = normalizeAbortError(error); | ||
| // Check if retry is needed for network error | ||
| const retry = await findApplicableRetry(options, { error }); | ||
| if (retry) { | ||
| const retryRequest = await handleRetry(request, retry, { error }, options); | ||
| if (retryRequest !== stop) { | ||
| cleanupRequest(options, requestTimer, abortHandler); | ||
| input = retryRequest; | ||
| continue; | ||
| } | ||
| } | ||
| // No retry - throw error | ||
| error = await afterError({ request, error, options }); | ||
| throw error; | ||
| } | ||
| finally { | ||
| cleanupRequest(options, requestTimer, abortHandler); | ||
| } | ||
| } | ||
| finally { | ||
| if (options.signal) | ||
| options.signal.onabort = null; | ||
| if (requestTimer) | ||
| clearTimeout(requestTimer); | ||
| } | ||
| } | ||
@@ -188,0 +257,0 @@ } |
+3
-3
| { | ||
| "name": "@applitools/req", | ||
| "version": "1.8.5", | ||
| "version": "1.8.6", | ||
| "description": "Applitools fetch-based request library", | ||
@@ -60,3 +60,3 @@ "keywords": [ | ||
| "dependencies": { | ||
| "@applitools/utils": "1.13.0", | ||
| "@applitools/utils": "1.14.0", | ||
| "abort-controller": "3.0.0", | ||
@@ -69,3 +69,3 @@ "http-proxy-agent": "5.0.0", | ||
| "@applitools/api-extractor": "^1.2.22", | ||
| "@applitools/test-server": "^1.3.4", | ||
| "@applitools/test-server": "^1.3.5", | ||
| "@applitools/test-utils": "^1.5.17", | ||
@@ -72,0 +72,0 @@ "@types/node": "^12.20.55", |
+13
-0
@@ -87,2 +87,14 @@ export type Stop = stop; | ||
| export default req; | ||
| export class RequestTimeoutError extends Error { | ||
| constructor(); | ||
| code: AbortCode.requestTimeout | AbortCode.connectionTimeout | AbortCode.retryTimeout; | ||
| } | ||
| export class ConnectionTimeoutError extends Error { | ||
| constructor(); | ||
| code: AbortCode.requestTimeout | AbortCode.connectionTimeout | AbortCode.retryTimeout; | ||
| } | ||
| export class RetryTimeoutError extends Error { | ||
| constructor(timeout: number); | ||
| code: AbortCode.requestTimeout | AbortCode.connectionTimeout | AbortCode.retryTimeout; | ||
| } | ||
| export type Fetch = (url: URL | (string | Request), init?: undefined | RequestInit) => Promise<Response>; | ||
@@ -99,2 +111,3 @@ export interface Options { | ||
| requestTimeout?: undefined | number | { base: number; perByte: number; }; | ||
| retryTimeout?: undefined | number; | ||
| retry?: undefined | Retry | Array<Retry>; | ||
@@ -101,0 +114,0 @@ hooks?: undefined | Hooks<this> | Array<Hooks<this>>; |
+13
-0
@@ -87,2 +87,14 @@ export type Stop = stop; | ||
| export default req; | ||
| export class RequestTimeoutError extends Error { | ||
| constructor(); | ||
| code: AbortCode.requestTimeout | AbortCode.connectionTimeout | AbortCode.retryTimeout; | ||
| } | ||
| export class ConnectionTimeoutError extends Error { | ||
| constructor(); | ||
| code: AbortCode.requestTimeout | AbortCode.connectionTimeout | AbortCode.retryTimeout; | ||
| } | ||
| export class RetryTimeoutError extends Error { | ||
| constructor(timeout: number); | ||
| code: AbortCode.requestTimeout | AbortCode.connectionTimeout | AbortCode.retryTimeout; | ||
| } | ||
| export type Fetch = (url: URL | (string | Request), init?: undefined | RequestInit) => Promise<Response>; | ||
@@ -99,2 +111,3 @@ export interface Options { | ||
| requestTimeout?: undefined | number | { base: number; perByte: number; }; | ||
| retryTimeout?: undefined | number; | ||
| retry?: undefined | Retry | Array<Retry>; | ||
@@ -101,0 +114,0 @@ hooks?: undefined | Hooks<this> | Array<Hooks<this>>; |
Sorry, the diff of this file is too big to display
Potential vulnerability
Supply chain riskInitial human review suggests the presence of a vulnerability in this package. It is pending further analysis and confirmation.
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
No README
QualityPackage does not have a README. This may indicate a failed publish or a low quality package.
Potential vulnerability
Supply chain riskInitial human review suggests the presence of a vulnerability in this package. It is pending further analysis and confirmation.
511305
5.52%17
6.25%11504
1.54%0
-100%857
Infinity%26
-3.7%+ Added
- Removed
Updated