Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce polling #55

Merged
merged 1 commit into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
88 changes: 66 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,28 +514,32 @@ Check Examples section below for more information.

## Comparison with another libraries

| Feature | fetchff | ofetch | wretch | axios | native fetch() |
| --------------------------------------- | ----------- | ------------ | ------------ | ------------ | -------------- |
| **Unified API Client** | ✅ | -- | -- | -- | -- |
| **Automatic Request Deduplication** | ✅ | -- | -- | -- | -- |
| **Customizable Error Handling** | ✅ | -- | ✅ | ✅ | -- |
| **Retries with exponential backoff** | ✅ | -- | -- | -- | -- |
| **Custom Retry logic** | ✅ | ✅ | ✅ | -- | -- |
| **Easy Timeouts** | ✅ | ✅ | ✅ | ✅ | -- |
| **Easy Cancellation** | ✅ | -- | -- | -- | -- |
| **Default Responses** | ✅ | -- | -- | -- | -- |
| **Global Configuration** | ✅ | -- | ✅ | ✅ | -- |
| **TypeScript Support** | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Built-in AbortController Support** | ✅ | -- | -- | -- | -- |
| **Interceptors** | ✅ | ✅ | ✅ | ✅ | -- |
| **Request and Response Transformation** | ✅ | ✅ | ✅ | ✅ | -- |
| **Integration with Libraries** | ✅ | ✅ | ✅ | ✅ | -- |
| **Request Queuing** | ✅ | -- | -- | -- | -- |
| **Multiple Fetching Strategies** | ✅ | -- | -- | -- | -- |
| **Dynamic URLs** | ✅ | -- | ✅ | -- | -- |
| **Automatic Retry on Failure** | ✅ | ✅ | -- | ✅ | -- |
| **Server-Side Rendering (SSR) Support** | ✅ | ✅ | -- | -- | -- |
| **Minimal Installation Size** | 🟢 (2.9 KB) | 🟡 (6.41 KB) | 🟢 (2.21 KB) | 🔴 (13.7 KB) | 🟢 (0 KB) |
| Feature | fetchff | ofetch | wretch | axios | native fetch() |
| -------------------------------------------------- | ----------- | ------------ | ------------ | ------------ | -------------- |
| **Unified API Client** | ✅ | -- | -- | -- | -- |
| **Automatic Request Deduplication** | ✅ | -- | -- | -- | -- |
| **Built-in Error Handling** | ✅ | -- | ✅ | -- | -- |
| **Customizable Error Handling** | ✅ | -- | ✅ | ✅ | -- |
| **Retries with exponential backoff** | ✅ | -- | -- | -- | -- |
| **Advanced Query Params handling** | ✅ | -- | -- | -- | -- |
| **Custom Retry logic** | ✅ | ✅ | ✅ | -- | -- |
| **Easy Timeouts** | ✅ | ✅ | ✅ | ✅ | -- |
| **Polling Functionality** | ✅ | -- | -- | -- | -- |
| **Easy Cancellation of stale (previous) requests** | ✅ | -- | -- | -- | -- |
| **Default Responses** | ✅ | -- | -- | -- | -- |
| **Custom adapters (fetchers)** | ✅ | -- | -- | ✅ | -- |
| **Global Configuration** | ✅ | -- | ✅ | ✅ | -- |
| **TypeScript Support** | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Built-in AbortController Support** | ✅ | -- | -- | -- | -- |
| **Request Interceptors** | ✅ | ✅ | ✅ | ✅ | -- |
| **Request and Response Transformation** | ✅ | ✅ | ✅ | ✅ | -- |
| **Integration with Libraries** | ✅ | ✅ | ✅ | ✅ | -- |
| **Request Queuing** | ✅ | -- | -- | -- | -- |
| **Multiple Fetching Strategies** | ✅ | -- | -- | -- | -- |
| **Dynamic URLs** | ✅ | -- | ✅ | -- | -- |
| **Automatic Retry on Failure** | ✅ | ✅ | -- | ✅ | -- |
| **Server-Side Rendering (SSR) Support** | ✅ | ✅ | -- | -- | -- |
| **Minimal Installation Size** | 🟢 (2.9 KB) | 🟡 (6.41 KB) | 🟢 (2.21 KB) | 🔴 (13.7 KB) | 🟢 (0 KB) |

Please mind that this table is for informational purposes only. All of these solutions differ. For example `swr` and `react-query` are more focused on React, re-rendering, query caching and keeping data in sync, while fetch wrappers like `fetchff` or `ofetch` aim to extend functionalities of native `fetch` so to reduce complexity of having to maintain various wrappers.

Expand Down Expand Up @@ -768,6 +772,46 @@ try {
}
```

### Polling Mechanism

Standard polling - re-fetch every n seconds.

```typescript
fetchff('https://api.example.com/books/all', null, {
pollingInterval: 5000, // Re-fetch the data every 5 seconds
shouldStopPolling(response, error, attempt) {
// Add some custom conditions
return attempt < 3; // Retry up to 3 times
},
onResponse(response) {
console.log('New response:', response);

return response;
},
onError(error) {
console.error('Request ultimately failed:', error);
},
});
```

Status Polling - until you get a certain data from an API. Let's say you have an API that returns the progress of a process, and you want to call that API until the process is finished.

```typescript
try {
const { data } = fetchff('https://api.example.com/books/all', null, {
pollingInterval: 5000, // Poll every 5 seconds
shouldStopPolling(response, error, attempt) {
// Add some custom conditions
return attempt < 3; // Retry up to 3 times
},
});

console.log('Request finally succeeded:', data);
} catch (error) {
console.error('Request ultimately failed:', error);
}
```

### ✔️ Advanced Usage with TypeScript and custom headers

```typescript
Expand Down
1 change: 1 addition & 0 deletions src/const.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const APPLICATION_JSON = 'application/json';
export const CONTENT_TYPE = 'Content-Type';
export const UNDEFINED = 'undefined';
export const OBJECT = 'object';
export const ABORT_ERROR = 'AbortError';
export const TIMEOUT_ERROR = 'TimeoutError';
export const CANCELLED_ERROR = 'CanceledError';
Expand Down
27 changes: 25 additions & 2 deletions src/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
ResponseError,
RequestHandlerReturnType,
CreatedCustomFetcherInstance,
PollingFunction,
} from './types/request-handler';
import type {
APIResponse,
Expand Down Expand Up @@ -43,8 +44,8 @@ const defaultConfig: RequestHandlerConfig = {
method: GET,
strategy: 'reject',
timeout: 30000,
rejectCancelled: false,
dedupeTime: 1000,
rejectCancelled: false,
withCredentials: false,
flattenResponse: false,
defaultResponse: null,
Expand Down Expand Up @@ -320,6 +321,11 @@ function createRequestHandler(
const timeout = getConfig<number>(fetcherConfig, 'timeout');
const isCancellable = getConfig<boolean>(fetcherConfig, 'cancellable');
const dedupeTime = getConfig<number>(fetcherConfig, 'dedupeTime');
const pollingInterval = getConfig<number>(fetcherConfig, 'pollingInterval');
const shouldStopPolling = getConfig<PollingFunction>(
fetcherConfig,
'shouldStopPolling',
);

const {
retries,
Expand All @@ -339,6 +345,7 @@ function createRequestHandler(
) as Required<RetryOptions>;

let attempt = 0;
let pollingAttempt = 0;
let waitTime: number = delay;

while (attempt <= retries) {
Expand Down Expand Up @@ -403,6 +410,22 @@ function createRequestHandler(

removeRequest(fetcherConfig);

// Polling logic
if (
pollingInterval &&
(!shouldStopPolling || !shouldStopPolling(response, pollingAttempt))
) {
// Restart the main retry loop
pollingAttempt++;

logger(`Polling attempt ${pollingAttempt}...`);

await delayInvocation(pollingInterval);

continue;
}

// If polling is not required, or polling attempts are exhausted
return outputResponse(response, requestConfig) as ResponseData &
FetchResponse<ResponseData>;
} catch (err) {
Expand All @@ -421,7 +444,7 @@ function createRequestHandler(
return outputErrorResponse(error, response, fetcherConfig);
}

logger(`Attempt ${attempt + 1} failed. Retrying in ${waitTime}ms...`);
logger(`Attempt ${attempt + 1} failed. Retry in ${waitTime}ms.`);

await delayInvocation(waitTime);

Expand Down
20 changes: 20 additions & 0 deletions src/types/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ export interface RetryOptions {
) => Promise<boolean>;
}

export type PollingFunction = <ResponseData = unknown>(
response: FetchResponse<ResponseData>,
attempt: number,
error?: ResponseError,
) => boolean;

/**
* ExtendedRequestConfig<D = any>
*
Expand Down Expand Up @@ -253,6 +259,20 @@ interface ExtendedRequestConfig<D = any> extends Omit<RequestInit, 'body'> {
* @default 1000 (1 second)
*/
dedupeTime?: number;

/**
* Interval in milliseconds between polling attempts.
* Set to < 1 to disable polling.
* @default 0 (disabled)
*/
pollingInterval?: number;

/**
* Function to determine if polling should stop based on the response.
* @param response - The response data.
* @returns `true` to stop polling, `false` to continue.
*/
shouldStopPolling?: PollingFunction;
}

interface BaseRequestHandlerConfig extends RequestConfig {
Expand Down
35 changes: 16 additions & 19 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { UNDEFINED } from './const';
import { OBJECT, UNDEFINED } from './const';
import type { HeadersObject, QueryParams, UrlPathParams } from './types';

export function isSearchParams(data: unknown): boolean {
return data instanceof URLSearchParams;
}

function makeUrl(url: string, encodedQueryString: string) {
return url.includes('?')
? `${url}&${encodedQueryString}`
: encodedQueryString
? `${url}?${encodedQueryString}`
: url;
}

/**
* Appends query parameters to a given URL.
*
Expand All @@ -22,11 +30,7 @@ export function appendQueryParams(url: string, params: QueryParams): string {
if (isSearchParams(params)) {
const encodedQueryString = params.toString();

return url.includes('?')
? `${url}&${encodedQueryString}`
: encodedQueryString
? `${url}?${encodedQueryString}`
: url;
return makeUrl(url, encodedQueryString);
}

// This is exact copy of what JQ used to do. It works much better than URLSearchParams
Expand All @@ -45,14 +49,11 @@ export function appendQueryParams(url: string, params: QueryParams): string {
if (Array.isArray(obj)) {
for (i = 0, len = obj.length; i < len; i++) {
buildParams(
prefix +
'[' +
(typeof obj[i] === 'object' && obj[i] ? i : '') +
']',
prefix + '[' + (typeof obj[i] === OBJECT && obj[i] ? i : '') + ']',
obj[i],
);
}
} else if (typeof obj === 'object' && obj !== null) {
} else if (typeof obj === OBJECT && obj !== null) {
for (key in obj) {
buildParams(prefix + '[' + key + ']', obj[key]);
}
Expand All @@ -76,11 +77,7 @@ export function appendQueryParams(url: string, params: QueryParams): string {
// Encode special characters as per RFC 3986, https://datatracker.ietf.org/doc/html/rfc3986
const encodedQueryString = queryStringParts.replace(/%5B%5D/g, '[]'); // Keep '[]' for arrays

return url.includes('?')
? `${url}&${encodedQueryString}`
: encodedQueryString
? `${url}?${encodedQueryString}`
: url;
return makeUrl(url, encodedQueryString);
}

/**
Expand Down Expand Up @@ -142,7 +139,7 @@ export function isJSONSerializable(value: any): boolean {
return false;
}

if (t === 'object') {
if (t === OBJECT) {
const proto = Object.getPrototypeOf(value);

// Check if the prototype is `Object.prototype` or `null` (plain object)
Expand Down Expand Up @@ -179,7 +176,7 @@ export async function delayInvocation(ms: number): Promise<boolean> {
export function flattenData(data: any): any {
if (
data &&
typeof data === 'object' &&
typeof data === OBJECT &&
typeof data.data !== UNDEFINED &&
Object.keys(data).length === 1
) {
Expand Down Expand Up @@ -213,7 +210,7 @@ export function processHeaders(
headers.forEach((value, key) => {
headersObject[key] = value;
});
} else if (typeof headers === 'object' && headers !== null) {
} else if (typeof headers === OBJECT && headers !== null) {
// Handle plain object
for (const [key, value] of Object.entries(headers)) {
// Normalize keys to lowercase as per RFC 2616 4.2
Expand Down
Loading
Loading