diff --git a/clients/javascript/lib/baseClient.ts b/clients/javascript/lib/baseClient.ts index e3f8dae3..9e9de261 100644 --- a/clients/javascript/lib/baseClient.ts +++ b/clients/javascript/lib/baseClient.ts @@ -12,6 +12,7 @@ import { UnauthorizedError, UnprocessableEntityError, } from './helpers/errors' +import axiosRetry, { isNetworkOrIdempotentRequestError } from 'axios-retry' /** * Configuration for the keep alive feature @@ -35,6 +36,20 @@ export type KeepAliveConfig = { keepAliveMsecs?: number } +/** + * Configuration for retry mechanism + */ +export type RetryConfig = { + /** + * Whether to enable retry mechanism (default: true) + */ + enabled: boolean + /** + * Maximum number of retry attempts (default: 3) + */ + maxRetries?: number +} + /** * Base configuration for the client */ @@ -53,6 +68,11 @@ export type ClientConfig = { * Timeout for requests in milliseconds (default: 1000) */ timeout?: number + + /** + * Retry configuration + */ + retry?: RetryConfig } /** @@ -64,6 +84,10 @@ export const DEFAULT_CONFIG: Required = { enabled: false, }, timeout: 1000, + retry: { + enabled: true, + maxRetries: 3, + }, } /** @@ -72,9 +96,7 @@ export const DEFAULT_CONFIG: Required = { * It shouldn't be exposed to the end user */ export abstract class NitteiBaseClient { - constructor(private readonly axiosClient: AxiosInstance) { - this.axiosClient = axiosClient - } + constructor(private readonly axiosClient: AxiosInstance) {} /** * Private generic function to call the API @@ -136,7 +158,6 @@ export abstract class NitteiBaseClient { path, params, }) - return res.data } @@ -261,6 +282,7 @@ export const createAxiosInstanceFrontend = ( args: { baseUrl: string timeout: number + retry: RetryConfig }, credentials: ICredentials ): AxiosInstance => { @@ -284,7 +306,18 @@ export const createAxiosInstanceFrontend = ( }, } - return axios.create(config) + const axiosClient = axios.create(config) + + if (args.retry.enabled) { + axiosRetry(axiosClient, { + retries: args.retry.maxRetries ?? 3, + retryDelay: axiosRetry.exponentialDelay, + retryCondition: isNetworkOrIdempotentRequestError, // Retry on network errors or idempotent requests (GET, PUT, DELETE) + shouldResetTimeout: true, + }) + } + + return axiosClient } /** @@ -300,6 +333,7 @@ export const createAxiosInstanceBackend = async ( baseUrl: string keepAlive: KeepAliveConfig timeout: number + retry: RetryConfig }, credentials: ICredentials ): Promise => { @@ -353,5 +387,16 @@ export const createAxiosInstanceBackend = async ( } } - return axios.create(config) + const axiosClient = axios.create(config) + + if (args.retry.enabled) { + axiosRetry(axiosClient, { + retries: args.retry.maxRetries ?? 3, + retryDelay: axiosRetry.exponentialDelay, + retryCondition: isNetworkOrIdempotentRequestError, // Retry on network errors or idempotent requests (GET, PUT, DELETE) + shouldResetTimeout: true, + }) + } + + return axiosClient } diff --git a/clients/javascript/lib/index.ts b/clients/javascript/lib/index.ts index 28dc6893..9221bce0 100644 --- a/clients/javascript/lib/index.ts +++ b/clients/javascript/lib/index.ts @@ -6,6 +6,7 @@ import { createAxiosInstanceFrontend, DEFAULT_CONFIG, type KeepAliveConfig, + type RetryConfig, } from './baseClient' import { NitteiCalendarClient, @@ -60,7 +61,11 @@ export const NitteiUserClient = ( // User clients should not keep the connection alive (usually on the frontend) const axiosClient = createAxiosInstanceFrontend( - { baseUrl: finalConfig.baseUrl, timeout: finalConfig.timeout }, + { + baseUrl: finalConfig.baseUrl, + timeout: finalConfig.timeout, + retry: finalConfig.retry, + }, creds ) @@ -94,6 +99,7 @@ export const NitteiClient = async ( baseUrl: finalConfig.baseUrl, keepAlive: finalConfig.keepAlive, timeout: finalConfig.timeout, + retry: finalConfig.retry, }, creds ) @@ -114,7 +120,7 @@ export const NitteiClient = async ( } // Client types -export type { ClientConfig, KeepAliveConfig } +export type { ClientConfig, KeepAliveConfig, RetryConfig } // Errors export * from './helpers/errors' diff --git a/clients/javascript/package.json b/clients/javascript/package.json index 3d985c93..dd48883d 100644 --- a/clients/javascript/package.json +++ b/clients/javascript/package.json @@ -42,6 +42,7 @@ "license": "MIT", "dependencies": { "axios": "0.28.0", + "axios-retry": "4.5.0", "dayjs": "1.11.13" }, "devDependencies": { diff --git a/examples/retry-mechanism.md b/examples/retry-mechanism.md new file mode 100644 index 00000000..411436d3 --- /dev/null +++ b/examples/retry-mechanism.md @@ -0,0 +1,95 @@ +# Retry mechanism for idempotent requests + +The Nittei JavaScript client supports automatic retry for idempotent requests (GET, PUT and DELETE) when encountering connection errors like timeouts, connection resets or internal server errors. + +## Configuration + +You can configure the retry mechanism when creating a client: + +```typescript +import { NitteiUserClient, type RetryConfig } from "@meetsmore/nittei"; + +const retryConfig: RetryConfig = { + enabled: true, + maxRetries: 3, // Maximum number of retry attempts (default: 3) +}; + +const client = NitteiUserClient({ + apiKey: "your-api-key", + retry: retryConfig, +}); +``` + +## How It Works + +The retry mechanism uses exponential backoff: + +- **Attempt 1**: Immediate request +- **Attempt 2**: Wait 1 second (baseDelay) +- **Attempt 3**: Wait 2 seconds (baseDelay \* 2^1) +- **Attempt 4**: Wait 4 seconds (baseDelay \* 2^2) + +The delay is capped at `maxDelay` to prevent excessive waiting times. + +## Retryable Errors + +The following errors will trigger a retry: + +- `ECONNRESET` - Connection reset +- `ETIMEDOUT` - Request timeout +- `ENOTFOUND` - DNS lookup failed +- `ENETUNREACH` - Network unreachable +- 5xx errors + +The client-side aborts won't be retried: + +- `ECONNABORTED` - Connection aborted + +## Example Usage + +```typescript +// Enable retry for all GET requests +const client = NitteiUserClient({ + apiKey: "your-api-key", + retry: { + enabled: true, + maxRetries: 3, + }, +}); + +// This GET request will automatically retry on connection errors +try { + const user = await client.user.me(); + console.log("User:", user); +} catch (error) { + // If all retries fail, the original error is thrown + console.error("Failed after retries:", error); +} +``` + +## Admin Client + +The retry mechanism also works with the admin client: + +```typescript +import { NitteiClient } from "@meetsmore/nittei"; + +const client = await NitteiClient({ + apiKey: "your-api-key", + retry: { + enabled: true, + maxRetries: 5, + }, +}); + +// All GET requests will retry on connection errors +const account = await client.account.me(); +``` + +## Notes + +- Only GET, PUT and DELETE requests can be retried +- 5xx errors are retried +- HTTP status codes (4xx) are not retried +- The retry mechanism is enabled by default (`enabled: true`) +- Each client instance can have its own retry configuration diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b875e04..109986cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: axios: specifier: 0.28.0 version: 0.28.0 + axios-retry: + specifier: 4.5.0 + version: 4.5.0(axios@0.28.0) dayjs: specifier: 1.11.13 version: 1.11.13 @@ -742,6 +745,11 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + axios-retry@4.5.0: + resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==} + peerDependencies: + axios: 0.x || 1.x + axios@0.28.0: resolution: {integrity: sha512-Tu7NYoGY4Yoc7I+Npf9HhUMtEEpV7ZiLH9yndTCoNhcpBH0kwcvFbzYN9/u5QKI5A6uefjsNNWaz5olJVYS62Q==} @@ -1142,6 +1150,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-retry-allowed@2.2.0: + resolution: {integrity: sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==} + engines: {node: '>=10'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -2590,6 +2602,11 @@ snapshots: asynckit@0.4.0: {} + axios-retry@4.5.0(axios@0.28.0): + dependencies: + axios: 0.28.0 + is-retry-allowed: 2.2.0 + axios@0.28.0: dependencies: follow-redirects: 1.15.9 @@ -2988,6 +3005,8 @@ snapshots: is-number@7.0.0: {} + is-retry-allowed@2.2.0: {} + is-stream@2.0.1: {} isexe@2.0.0: {}