From f7cdda2d12a2acd6eb9ef9b1e8d0709eb05c0e69 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 15 Jul 2025 17:54:28 +0200 Subject: [PATCH 1/4] feat: enhance retry logic to handle additional retry headers parsing --- src/retry-handler.ts | 63 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/src/retry-handler.ts b/src/retry-handler.ts index c6b3096b..a7e67b85 100644 --- a/src/retry-handler.ts +++ b/src/retry-handler.ts @@ -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. @@ -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; @@ -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); From 327889d5a836c2cce4a7f7cda103683eafb7b811 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 19 Jul 2025 01:34:30 +0200 Subject: [PATCH 2/4] feat: add type definitions to exports and update size limits --- package.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 17fddfff..f2bc33b4 100644 --- a/package.json +++ b/package.json @@ -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" @@ -24,6 +25,7 @@ } }, "./react": { + "types": "./dist/index.d.ts", "import": "./dist/react/index.mjs", "require": "./dist/react/index.cjs" } @@ -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", From b21410720182f578280258a2c2de17560e3385e3 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 19 Jul 2025 14:26:48 +0200 Subject: [PATCH 3/4] fix: improve response parsing for multipart and URL-encoded forms --- README.md | 2 +- src/response-parser.ts | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a5783659..e339fe36 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/response-parser.ts b/src/response-parser.ts index d7513191..381c9796 100644 --- a/src/response-parser.ts +++ b/src/response-parser.ts @@ -55,14 +55,19 @@ 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 + data = await response.blob(); // Parse as blob } else if (mimeType.startsWith('text/')) { data = await response.text(); // Parse as text } else { From ad35ee4d94f3d9a437076cb00143390b086fe47e Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 19 Jul 2025 14:56:33 +0200 Subject: [PATCH 4/4] feat: enhance response parsing to handle string data and JSON conversion --- src/response-parser.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/response-parser.ts b/src/response-parser.ts index 381c9796..dec5a7da 100644 --- a/src/response-parser.ts +++ b/src/response-parser.ts @@ -6,6 +6,7 @@ import { CONTENT_TYPE, FUNCTION, OBJECT, + STRING, } from './constants'; import { DefaultResponse, @@ -68,18 +69,21 @@ export async function parseResponseData< typeof response.blob === FUNCTION ) { data = await response.blob(); // Parse as blob - } else if (mimeType.startsWith('text/')) { - data = await response.text(); // Parse as text } 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