Skip to content
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ You can also use all native [`fetch()` settings](https://developer.mozilla.org/e
| refetchOnFocus | `boolean` | `false` | When set to `true`, automatically revalidates (refetches) data when the browser window regains focus. **Note: This bypasses the cache and always makes a fresh network request** to ensure users see the most up-to-date data when they return to your application from another tab or window. Particularly useful for applications that display real-time or frequently changing data, but should be used judiciously to avoid unnecessary network traffic. |
| refetchOnReconnect | `boolean` | `false` | When set to `true`, automatically revalidates (refetches) data when the browser regains internet connectivity after being offline. **This uses background revalidation to silently update data** without showing loading states to users. Helps ensure your application displays fresh data after network interruptions. Works by listening to the browser's `online` event. |
| logger | `Logger` | `null` | You can additionally specify logger object with your custom logger to automatically log the errors to the console. It should contain at least `error` and `warn` functions. |
| fetcher | `CustomFetcher` | `undefined` | A custom fetcher async function. By default, the native `fetch()` is used. If you use your own fetcher, default response parsing e.g. `await response.json()` call will be skipped. Your fetcher should return data. |
| fetcher | `CustomFetcher` | `undefined` | A custom fetcher async function. By default, the native `fetch()` is used. If you use your own fetcher, default response parsing e.g. `await response.json()` call will be skipped. Your fetcher should return response object / data directly. |

> 📋 **Additional Settings Available:**
> The table above shows the most commonly used settings. Many more advanced configuration options are available and documented in their respective sections below, including:
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"unpkg": "./dist/browser/index.mjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": {
"node": "./dist/node/index.js",
"default": "./dist/browser/index.mjs"
Expand All @@ -24,6 +25,7 @@
}
},
"./react": {
"types": "./dist/index.d.ts",
"import": "./dist/react/index.mjs",
"require": "./dist/react/index.cjs"
}
Expand Down Expand Up @@ -69,15 +71,15 @@
"size-limit": [
{
"path": "dist/browser/index.mjs",
"limit": "5.5 KB"
"limit": "5.99 KB"
},
{
"path": "dist/browser/index.global.js",
"limit": "5.6 KB"
"limit": "5.99 KB"
},
{
"path": "dist/node/index.js",
"limit": "5.5 KB"
"limit": "5.99 KB"
},
{
"path": "dist/react/index.mjs",
Expand Down
43 changes: 26 additions & 17 deletions src/response-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CONTENT_TYPE,
FUNCTION,
OBJECT,
STRING,
} from './constants';
import {
DefaultResponse,
Expand Down Expand Up @@ -55,26 +56,34 @@ export async function parseResponseData<
try {
if (mimeType.includes(APPLICATION_JSON) || mimeType.includes('+json')) {
data = await response.json(); // Parse JSON response
} else if (mimeType.includes('multipart/form-data')) {
data = await response.formData(); // Parse as FormData
} else if (mimeType.includes(APPLICATION_CONTENT_TYPE + 'octet-stream')) {
data = await response.blob(); // Parse as blob
} else if (
mimeType.includes(APPLICATION_CONTENT_TYPE + 'x-www-form-urlencoded')
(mimeType.includes('multipart/form-data') || // Parse as FormData
mimeType.includes(
APPLICATION_CONTENT_TYPE + 'x-www-form-urlencoded', // Handle URL-encoded forms
)) &&
typeof response.formData === FUNCTION
) {
data = await response.formData();
} else if (
mimeType.includes(APPLICATION_CONTENT_TYPE + 'octet-stream') &&
typeof response.blob === FUNCTION
) {
data = await response.formData(); // Handle URL-encoded forms
} else if (mimeType.startsWith('text/')) {
data = await response.text(); // Parse as text
data = await response.blob(); // Parse as blob
} else {
try {
const responseClone = response.clone();

// Handle edge case of no content type being provided... We assume JSON here.
data = await responseClone.json();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_e) {
// Handle streams
data = await response.text();
data = await response.text();

if (typeof data === STRING) {
const trimmed = data.trim();
if (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
try {
data = JSON.parse(trimmed);
} catch {
// leave as text if parsing fails
}
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down
63 changes: 49 additions & 14 deletions src/retry-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import type { FetchResponse, RetryConfig, RetryFunction } from './types';
import { delayInvocation, timeNow } from './utils';
import { generateCacheKey } from './cache-manager';

function getMsFromHttpDate(dateString: string): number | null {
const ms = Date.parse(dateString) - timeNow();

if (!isNaN(ms)) {
return Math.max(0, Math.floor(ms));
}
return null;
}

/**
* Calculates the number of milliseconds to wait before retrying a request,
* based on the `Retry-After` HTTP header in the provided response.
Expand All @@ -17,26 +26,52 @@ import { generateCacheKey } from './cache-manager';
export function getRetryAfterMs(
extendedResponse: FetchResponse | null,
): number | null {
const retryAfter = extendedResponse?.headers?.['retry-after'];

if (!retryAfter) {
if (!extendedResponse) {
return null;
}

// Try parsing as seconds
const seconds = Number(retryAfter);
const headers = extendedResponse.headers || {};
const retryAfter = headers['retry-after'];

if (retryAfter) {
// Try parsing as seconds
const seconds = Number(retryAfter);

if (!isNaN(seconds) && seconds >= 0) {
return seconds * 1000;
}

const ms = getMsFromHttpDate(retryAfter);

if (!isNaN(seconds) && seconds >= 0) {
return seconds * 1000;
if (ms !== null) {
return ms;
}
}

// Try parsing as HTTP-date
const date = new Date(retryAfter);
// Headers are already in lowercase
const RATELIMIT_RESET = 'ratelimit-reset';

// Unix timestamp when the rate limit window resets (relative to current time)
// Fallback to checking 'ratelimit-reset-after' OR 'x-ratelimit-reset-after' headers
const rateLimitResetAfter =
headers[RATELIMIT_RESET + '-after'] ||
headers['x-' + RATELIMIT_RESET + '-after'];

if (rateLimitResetAfter) {
const seconds = Number(rateLimitResetAfter);

if (!isNaN(seconds)) {
return seconds * 1000;
}
}

if (!isNaN(date.getTime())) {
const ms = date.getTime() - timeNow();
// ISO 8601 datetime when the rate limit resets
// Fallback to checking 'ratelimit-reset-at' 'x-ratelimit-reset-at' headers
const rateLimitResetAt =
headers[RATELIMIT_RESET + '-at'] || headers['x-' + RATELIMIT_RESET + '-at'];

return ms > 0 ? ms : 0;
if (rateLimitResetAt) {
return getMsFromHttpDate(rateLimitResetAt);
}

return null;
Expand Down Expand Up @@ -139,8 +174,8 @@ export async function withRetry<
}

// If we should not stop retrying, continue to the next attempt
// If the error status is 429 (Too Many Requests), handle rate limiting
if (error.status === 429) {
// Handle rate limiting if the error status is 429 (Too Many Requests) or 503 (Service Unavailable)
if (error.status === 429 || error.status === 503) {
// Try to extract the "Retry-After" value from the response headers
const retryAfterMs = getRetryAfterMs(output);

Expand Down
Loading