Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 173 additions & 5 deletions clients/javascript/lib/baseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@ export type KeepAliveConfig = {
keepAliveMsecs?: number
}

/**
* Configuration for retry mechanism
*/
export type RetryConfig = {
/**
* Whether to enable retry for GET requests (default: true)
*/
enabled: boolean
/**
* Maximum number of retry attempts (default: 3)
*/
maxRetries?: number
/**
* Base delay between retries in milliseconds (default: 300)
* Will be multiplied by 2^attempt for exponential backoff
*/
baseDelay?: number
/**
* Maximum delay between retries in milliseconds (default: 3000)
*/
maxDelay?: number
}

/**
* Base configuration for the client
*/
Expand All @@ -53,6 +76,11 @@ export type ClientConfig = {
* Timeout for requests in milliseconds (default: 1000)
*/
timeout?: number

/**
* Retry configuration for GET requests
*/
retry?: RetryConfig
}

/**
Expand All @@ -64,6 +92,12 @@ export const DEFAULT_CONFIG: Required<ClientConfig> = {
enabled: false,
},
timeout: 1000,
retry: {
enabled: true,
maxRetries: 3,
baseDelay: 300,
maxDelay: 3000,
},
}

/**
Expand All @@ -72,8 +106,18 @@ export const DEFAULT_CONFIG: Required<ClientConfig> = {
* It shouldn't be exposed to the end user
*/
export abstract class NitteiBaseClient {
constructor(private readonly axiosClient: AxiosInstance) {
this.axiosClient = axiosClient
private readonly retryConfig: Required<RetryConfig>

constructor(
private readonly axiosClient: AxiosInstance,
retryConfig?: RetryConfig
) {
this.retryConfig = {
enabled: retryConfig?.enabled ?? true,
maxRetries: retryConfig?.maxRetries ?? 3,
baseDelay: retryConfig?.baseDelay ?? 300,
maxDelay: retryConfig?.maxDelay ?? 3000,
}
}

/**
Expand Down Expand Up @@ -120,7 +164,132 @@ export abstract class NitteiBaseClient {
}

/**
* Make a GET request to the API
* Private generic function to call the API
* @private
* @param method - HTTP method to use
* @param path - path to the endpoint
* @param data - data to send to the server
* @param params - query parameters
* @returns Axios response
*/
private async callApiWithRetry<T>({
method,
path,
data,
params,
}: {
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
path: string
data?: unknown
params?: Record<string, unknown>
}): Promise<AxiosResponse<T>> {
// Only retry GET requests
const shouldRetry = method === 'GET' && this.retryConfig.enabled

// If we don't need to retry, we can just make the request
if (!shouldRetry) {
return await this.callApi<T>({ method, path, data, params })
}

// Retry mechanism
let res: AxiosResponse<T> | undefined = undefined
let lastError: unknown

for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
Comment thread
mm-derek marked this conversation as resolved.
Outdated
try {
res = await this.axiosClient({
method,
url: path,
data,
params,
})
break
} catch (error) {
lastError = error

// If this is the last attempt or the error is not retryable, throw
if (
attempt === this.retryConfig.maxRetries ||
!this.isRetryableError(error)
) {
throw error
}

// Wait before retrying (exponential backoff)
const delay = this.calculateDelay(attempt)
await this.sleep(delay)
}
}

if (lastError) {
throw new Error(
`Unknown error (no status code) (${(lastError as Error)?.message ?? lastError})`
)
}

if (!res) {
// This should never be reached, as we should have a response if lastError is not defined, but TS requires it
throw new Error('No response from the server')
}

this.handleStatusCode(res)
return res
}

/**
* Check if an error is retryable (connection errors, timeouts, etc.)
* @private
* @param error - the error to check
* @returns true if the error is retryable
*/
private isRetryableError(error: unknown): boolean {
if (error instanceof AxiosError) {
// Retry on network errors, timeouts, and connection resets
return (
error.code === 'ECONNRESET' ||
error.code === 'ECONNABORTED' ||
error.code === 'ETIMEDOUT' ||
error.code === 'ENOTFOUND' ||
error.code === 'ENETUNREACH' ||
error.message.includes('timeout') ||
error.message.includes('network') ||
error.message.includes('connection')
)
}

// For non-Axios errors, check if it's a connection-related error
const errorMessage = (error as Error)?.message ?? String(error)
return (
errorMessage.includes('timeout') ||
errorMessage.includes('network') ||
errorMessage.includes('connection') ||
errorMessage.includes('ECONNRESET') ||
errorMessage.includes('ECONNABORTED')
)
}

/**
* Sleep for a given number of milliseconds
* @private
* @param ms - milliseconds to sleep
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

/**
* Calculate delay for exponential backoff
* @private
* @param attempt - current attempt number (0-based)
* @returns delay in milliseconds
*/
private calculateDelay(attempt: number): number {
const delay = this.retryConfig.baseDelay * 2 ** attempt
return Math.min(delay, this.retryConfig.maxDelay)
}

/**
* Make a GET request to the API with retry mechanism
* @private
* @param path - path to the endpoint
* @param params - query parameters
Expand All @@ -131,12 +300,11 @@ export abstract class NitteiBaseClient {
path: string,
params: Record<string, unknown> = {}
): Promise<T> {
const res = await this.callApi<T>({
const res = await this.callApiWithRetry<T>({
method: 'GET',
path,
params,
})

return res.data
}

Expand Down
27 changes: 14 additions & 13 deletions clients/javascript/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createAxiosInstanceFrontend,
DEFAULT_CONFIG,
type KeepAliveConfig,
type RetryConfig,
} from './baseClient'
import {
NitteiCalendarClient,
Expand Down Expand Up @@ -65,11 +66,11 @@ export const NitteiUserClient = (
)

return Object.freeze({
calendar: new NitteiCalendarUserClient(axiosClient),
events: new NitteiEventUserClient(axiosClient),
service: new NitteiServiceUserClient(axiosClient),
schedule: new NitteiScheduleUserClient(axiosClient),
user: new NitteiUserUserClient(axiosClient),
calendar: new NitteiCalendarUserClient(axiosClient, finalConfig.retry),
events: new NitteiEventUserClient(axiosClient, finalConfig.retry),
service: new NitteiServiceUserClient(axiosClient, finalConfig.retry),
schedule: new NitteiScheduleUserClient(axiosClient, finalConfig.retry),
user: new NitteiUserUserClient(axiosClient, finalConfig.retry),
// Axios client exposed so that the user can use it
// - For adding interceptors
// - For making custom requests
Expand Down Expand Up @@ -99,13 +100,13 @@ export const NitteiClient = async (
)

return Object.freeze({
account: new NitteiAccountClient(axiosClient),
events: new NitteiEventClient(axiosClient),
calendar: new NitteiCalendarClient(axiosClient),
user: new _NitteiUserClient(axiosClient),
service: new NitteiServiceClient(axiosClient),
schedule: new NitteiScheduleClient(axiosClient),
health: new NitteiHealthClient(axiosClient),
account: new NitteiAccountClient(axiosClient, finalConfig.retry),
events: new NitteiEventClient(axiosClient, finalConfig.retry),
calendar: new NitteiCalendarClient(axiosClient, finalConfig.retry),
user: new _NitteiUserClient(axiosClient, finalConfig.retry),
service: new NitteiServiceClient(axiosClient, finalConfig.retry),
schedule: new NitteiScheduleClient(axiosClient, finalConfig.retry),
health: new NitteiHealthClient(axiosClient, finalConfig.retry),
// Axios client exposed so that the user can use it
// - For adding interceptors
// - For making custom requests
Expand All @@ -114,7 +115,7 @@ export const NitteiClient = async (
}

// Client types
export type { ClientConfig, KeepAliveConfig }
export type { ClientConfig, KeepAliveConfig, RetryConfig }

// Errors
export * from './helpers/errors'
Expand Down
96 changes: 96 additions & 0 deletions examples/retry-mechanism.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Retry Mechanism for GET Requests

The Nittei JavaScript client supports automatic retry for GET requests when encountering connection errors like timeouts or connection resets.

## 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)
baseDelay: 300, // Base delay in milliseconds (default: 300)
maxDelay: 5000, // Maximum delay in milliseconds (default: 5000)
};

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
- `ECONNABORTED` - Connection aborted
- `ETIMEDOUT` - Request timeout
- `ENOTFOUND` - DNS lookup failed
- `ENETUNREACH` - Network unreachable
- Any error message containing "timeout", "network", or "connection"

## Example Usage

```typescript
// Enable retry for all GET requests
const client = NitteiUserClient({
apiKey: "your-api-key",
retry: {
enabled: true,
maxRetries: 3,
baseDelay: 1000,
},
});

// 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,
baseDelay: 500,
maxDelay: 5000,
},
});

// All GET requests will retry on connection errors
const account = await client.account.me();
```

## Notes

- Only GET requests are retried (POST, PUT, DELETE requests are not retried)
- HTTP status codes (4xx, 5xx) are not retried - only connection-level errors
- The retry mechanism is enabled by default (`enabled: true`)
- Each client instance can have its own retry configuration