{"_id":"@dmytromykhailiuk/retry-request","_rev":"2-ecdb72928b1c6785e39bc91835f3640b","name":"@dmytromykhailiuk/retry-request","dist-tags":{"latest":"1.0.1"},"versions":{"1.0.0":{"name":"@dmytromykhailiuk/retry-request","version":"1.0.0","keywords":["retry","retries","backoff","exponential-backoff","resilience","fetch","request","network","offline","offline-first","abort","pwa","typescript","type-safe"],"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","_id":"@dmytromykhailiuk/retry-request@1.0.0","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"homepage":"https://github.com/dmytromykhailiuk/retry-request#readme","bugs":{"url":"https://github.com/dmytromykhailiuk/retry-request/issues"},"dist":{"shasum":"4f948668d3750d1205115cc55f8656cc21f5de25","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/retry-request/-/retry-request-1.0.0.tgz","fileCount":9,"integrity":"sha512-SF3D9INAfdCbtkeCnGuHRQ3rZG8Z5nGPJRM9FNc0rex1E9tgYbCdVEn7p+V3MZ4tPYjonWDp5g33rUz6d7oqew==","signatures":[{"sig":"MEQCIGO+Q8/f61IMvJjSU0+E1UnS028ugUYt2D2gz3lHrnHGAiAtEC3I+I/nYKPofmFKkaklW2V9iVTtw4Nj7aoiPsTB1g==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":60044},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"a8008abb16008608def240f4895dd60745684182","scripts":{"dev":"tsup --watch","lint":"biome check .","test":"vitest run","build":"tsup","format":"biome format --write .","lint:fix":"biome check --write .","typecheck":"tsc --noEmit","playground":"vite --config vite.playground.config.ts","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"repository":{"url":"git+https://github.com/dmytromykhailiuk/retry-request.git","type":"git"},"_npmVersion":"11.6.2","description":"A retry loop that knows whether the network is actually there — exponential backoff, abort support, and retries that park while offline instead of burning attempts. Typed, tested, tiny.","directories":{},"sideEffects":false,"_nodeVersion":"24.12.0","dependencies":{"@dmytromykhailiuk/network-connection":"^1.0.1"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.3.5","vite":"^5.4.11","jsdom":"^25.0.1","vitest":"^2.1.8","typescript":"^5.7.3","@types/node":"^22.10.5","@biomejs/biome":"^1.9.4"},"_npmOperationalInternal":{"tmp":"tmp/retry-request_1.0.0_1785338780682_0.7044917197534","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@dmytromykhailiuk/retry-request","version":"1.0.1","description":"A retry loop that knows whether the network is actually there — exponential backoff, abort support, and retries that park while offline instead of burning attempts. Typed, tested, tiny.","type":"module","sideEffects":false,"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","keywords":["retry","retries","backoff","exponential-backoff","resilience","fetch","request","network","offline","offline-first","abort","pwa","typescript","type-safe"],"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"scripts":{"build":"tsup","dev":"tsup --watch","playground":"vite --config vite.playground.config.ts","typecheck":"tsc --noEmit","test":"vitest run","test:watch":"vitest","lint":"biome check .","lint:fix":"biome check --write .","format":"biome format --write .","prepublishOnly":"npm run build"},"engines":{"node":">=18"},"dependencies":{"@dmytromykhailiuk/network-connection":"^1.0.1"},"devDependencies":{"@biomejs/biome":"^1.9.4","@types/node":"^22.10.5","jsdom":"^25.0.1","tsup":"^8.3.5","typescript":"^5.7.3","vite":"^5.4.11","vitest":"^2.1.8"},"repository":{"type":"git","url":"git+https://github.com/dmytromykhailiuk/retry-request.git"},"bugs":{"url":"https://github.com/dmytromykhailiuk/retry-request/issues"},"homepage":"https://dmytromykhailiuk.github.io/retry-request/","gitHead":"88d4707b70f68714484aae287f5475ac55f69f44","_id":"@dmytromykhailiuk/retry-request@1.0.1","_nodeVersion":"24.12.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-sIR3JULEQZqD+3XY++hmr7DS6No89E/x/IYnroWjdH0C4objbxc5x5+HC2JHZmtSX17gQ2pbd/YJ7A5Mud17EQ==","shasum":"162e3407c5d89fa05f16dcafe476b2807174f16c","tarball":"https://registry.npmjs.org/@dmytromykhailiuk/retry-request/-/retry-request-1.0.1.tgz","fileCount":9,"unpackedSize":60037,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIC/BVY0qN3+H98dhA/TR8IBDr731Smnff9sw1p/9hpr6AiEAuxzazeCCilkokgs9mw/mECsSMQeFLxZJM6G7z2a2B6I="}]},"_npmUser":{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"},"directories":{},"maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/retry-request_1.0.1_1786639328626_0.5687316959209521"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-29T15:26:20.265Z","modified":"2026-08-13T16:42:08.966Z","1.0.0":"2026-07-29T15:26:20.835Z","1.0.1":"2026-08-13T16:42:08.787Z"},"bugs":{"url":"https://github.com/dmytromykhailiuk/retry-request/issues"},"author":{"name":"Dmytro Mykhailiuk","email":"dimamykhayluk@gmail.com"},"license":"MIT","homepage":"https://dmytromykhailiuk.github.io/retry-request/","keywords":["retry","retries","backoff","exponential-backoff","resilience","fetch","request","network","offline","offline-first","abort","pwa","typescript","type-safe"],"repository":{"type":"git","url":"git+https://github.com/dmytromykhailiuk/retry-request.git"},"description":"A retry loop that knows whether the network is actually there — exponential backoff, abort support, and retries that park while offline instead of burning attempts. Typed, tested, tiny.","maintainers":[{"name":"dmytromykhailiuk","email":"dimamykhayluk@gmail.com"}],"readme":"# @dmytromykhailiuk/retry-request\n\nA retry loop that knows whether the network is actually there — exponential backoff, abort\nsupport, and attempts that park while offline instead of being spent.\n\n> **Full documentation:** open [Docs](https://dmytromykhailiuk.github.io/retry-request/) in a\n> browser — every option, with examples, a table of contents and cross-links. This README is the\n> short form.\n\n> ⚠️ **`NetworkConnection.init()` must be called first.** This is a requirement, not an optional\n> integration: every call reads the network state from [NetworkConnection Docs](https://dmytromykhailiuk.github.io/network-connection),\n> so a `retryRequest` made before that setup rejects immediately with\n> `[retry-request] NetworkConnection.init() must be called before retryRequest()` and never starts\n> your attempt. See [Setup](#setup).\n\nA plain retry loop treats every failure the same. Offline, that is the worst possible behaviour:\nthe four attempts you budgeted for a flaky server are spent in eight seconds on requests that never\nleft the device, and the call fails while the user is still walking towards the lift. Retries are\nfor failures that might not repeat, and a failure that repeats every time until the connection is\nback is not one of them.\n\nSo this loop asks `NetworkConnection` first. An attempt does not start while the network is down —\nthe call **parks**, costing nothing, and starts the moment the connection is verified back. A\nbackoff already in progress **ends early** when the connection returns. And\n`retryOnlyOnConnectionFailure` lets you say the thing you actually mean: _retry a dropped\nconnection, never a server that answered_.\n\n## Install\n\n```sh\nnpm i @dmytromykhailiuk/retry-request\n```\n\n## Setup\n\nOnce, at startup, before anything calls `retryRequest`:\n\n```ts\nimport { NetworkConnection } from \"@dmytromykhailiuk/network-connection\";\n\n// Any URL your server answers cheaply. A 404 still proves the network is\n// reachable — this measures connectivity, not server health.\nawait NetworkConnection.init(\"/healthcheck\", {\n  pingInterval: 30_000, // catch the silent drops: Wi-Fi up, no internet\n});\n```\n\nThe same applies after `NetworkConnection.destroy()`: the layer is gone, so the next call is\nrefused the same way, and calls already in flight reject with the connection layer's own error —\na call parked waiting for a reconnection has just lost the only thing that could ever wake it.\n\nThere is no fallback to a plain retry, on purpose. Without the connection layer, an offline failure\nand a server error are indistinguishable, parking is impossible and a backoff can never end early —\nbetter to say so at startup than to silently degrade in production.\n\n## Quick start\n\n```ts\nimport { retryRequest } from \"@dmytromykhailiuk/retry-request\";\n\nconst profile = await retryRequest(\n  async () => {\n    const response = await fetch(\"/api/profile\");\n    if (!response.ok) throw new Error(`HTTP ${response.status}`);\n    return response.json();\n  },\n  { maxRetries: 4, retryBaseDelay: 500 }\n);\n```\n\nFour retries, waiting 500 ms, 1 s, 2 s and 4 s — and none of that time is\nspent while the device is offline. Note the `throw`: `fetch` resolves for a 500 as happily as for a\n200, so a response you consider a failure has to be turned into one.\n\n**Nothing is retried by default.** `maxRetries` defaults to `0`, so wrapping a call and passing no\noptions runs it exactly once.\n\n## API\n\n```ts\nretryRequest<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>\n\ninterface RetryOptions {\n  maxRetries?: number;                       // default: 0        — extra attempts, may be Infinity\n  retryBaseDelay?: number;                   // default: 500      — ms before the first retry\n  exponentialBackoff?: boolean;              // default: true     — double the delay each failure\n  retryOnlyOnConnectionFailure?: boolean;    // default: false    — never retry a real answer\n  ignoreConnectionForFirstAttempt?: boolean; // default: false    — attempt 1 may run offline\n  maxDelay?: number;                         // default: Infinity — ceiling for one delay\n  shouldRetry?: (error: unknown, attempt: number) => boolean | Promise<boolean>;\n  onRetry?: (info: { error: unknown; attempt: number; delay: number }) => void;\n  signal?: AbortSignal;\n}\n```\n\n`fn` is called with no arguments, every time. It must be a _function_, not a promise — a promise\ncan only be awaited once, and a retry starts the work again from the beginning.\n\n## How a call unfolds\n\n```\nonce:   the options are validated, and NetworkConnection must be initialized\n\nper attempt:\n  1. aborted? reject with signal.reason\n  2. wait until the network is confirmed online\n     (skipped for attempt 1 with ignoreConnectionForFirstAttempt)\n  3. run the attempt — resolved? that is the result, done\n\nwhen it rejects:\n  4. aborted? reject with signal.reason\n  5. out of budget? reject with the attempt's own error\n  6. retryOnlyOnConnectionFailure and the network is up? reject with it too\n  7. shouldRetry says no? reject with it too\n  8. call onRetry, then wait: the backoff delay, or the connection coming\n     back, or an abort — whichever happens first\n  9. back to 1\n```\n\nTwo consequences worth stating plainly. **The budget is checked before anything else**, so\n`shouldRetry` and `onRetry` never run for the failure that ends the call. And **waiting for the\nnetwork is not an attempt**: a call parked offline for ten minutes has spent none of its retries.\n\n## Delays\n\n| After failure | `exponentialBackoff: true` | `false` |\n| ------------- | -------------------------- | ------- |\n| 1st           | 500 ms                     | 500 ms  |\n| 2nd           | 1 s                        | 500 ms  |\n| 3rd           | 2 s                        | 500 ms  |\n| 10th          | 4 min 16 s                 | 500 ms  |\n\nDoubling grows faster than people expect, so anything with more than a handful of retries wants\n`maxDelay` — `maxDelay: 30_000` turns `500, 1000, 2000, …` into `500, 1000, 2000, …, 30000,\n30000, …`. There is always a ceiling regardless: a browser timer holds its delay in a 32-bit\ninteger, and above ~24.8 days it overflows and fires _immediately_ — every delay is clamped below\nthat, so a long backoff can never turn into a busy loop.\n\n## maxRetries: Infinity\n\nExplicitly supported: the budget check can never fail, so the call keeps trying until the work\nsucceeds or something stops it. It is the right shape for a background sync that must eventually go\nthrough — and it is a promise that may never settle, so give it a way out with `maxDelay` and a\n`signal`:\n\n```ts\nawait retryRequest(() => pushPendingChanges(), {\n  maxRetries: Number.POSITIVE_INFINITY,\n  retryBaseDelay: 1000,\n  maxDelay: 60_000, // never slower than once a minute\n  signal: sessionController.signal,\n});\n```\n\nAn unbounded loop is not a busy loop: while the device is offline it is parked, not spinning.\n\n## Retrying only what is worth retrying\n\n`retryOnlyOnConnectionFailure` is what makes a retry loop safe around non-idempotent work: a `POST`\nthat reached the server and came back 500 is not retried, while the same `POST` killed by a dying\nconnection is.\n\n```ts\nawait retryRequest(() => postOrder(body), {\n  maxRetries: 5,\n  retryOnlyOnConnectionFailure: true,\n});\n```\n\nIt narrows the retries to the failures where the request most likely never arrived — but \"most\nlikely\" is the honest wording: a request can reach the server and be committed there while the\nresponse dies on the way back. For anything that must not happen twice, keep the option _and_ make\nthe endpoint idempotent.\n\n`shouldRetry` is the per-error version of the same idea:\n\n```ts\nconst RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);\n\nawait retryRequest(load, {\n  maxRetries: 4,\n  // Anything without a status — a parse failure, a dropped connection —\n  // has nothing to judge, so it stays retryable.\n  shouldRetry: (error) =>\n    !(error instanceof HttpError) || RETRYABLE.has(error.status),\n  onRetry: ({ attempt, delay }) =>\n    console.warn(`retry ${attempt} in ${Math.round(delay)}ms`),\n});\n```\n\n## Cancellation\n\n`signal` ends all three states a call can be in — parked offline, backing off, or between attempts:\n\n```ts\nconst controller = new AbortController();\n\nconst load = retryRequest(\n  // Passing the signal to fetch as well cancels the request in flight.\n  () => fetch(url, { signal: controller.signal }).then((r) => r.json()),\n  { maxRetries: 5, signal: controller.signal }\n);\n\nonCleanup(() => controller.abort());\n```\n\nAn abort always wins: if the signal fires while an attempt is being torn down, the call rejects\nwith `signal.reason` rather than with whatever the dying attempt threw.\n\nThere is no `timeout` option — a timeout belongs to the request, not to the loop, and\n`AbortSignal.timeout(5000)` passed to `fetch` composes with the call-wide signal.\n\n## What a call rejects with\n\n| Situation                                   | Rejection                                                 |\n| ------------------------------------------- | --------------------------------------------------------- |\n| Retries exhausted                           | the last attempt's own error, never wrapped               |\n| `shouldRetry` returned `false`              | that attempt's error                                      |\n| `retryOnlyOnConnectionFailure` while online | that attempt's error                                      |\n| `shouldRetry` or `onRetry` threw            | the hook's error                                          |\n| The signal aborted                          | `signal.reason`                                           |\n| An option is out of range                   | `Error(\"[retry-request] …\")`, before any attempt          |\n| `NetworkConnection` not initialized         | `Error(\"[retry-request] NetworkConnection.init() …\")`     |\n| `NetworkConnection.destroy()` mid-call      | `Error(\"[network-connection] destroyed while waiting …\")` |\n\nThe error identity is preserved, so `instanceof` checks, `error.status` and error-reporting\nfingerprints behave exactly as they would without the wrapper. Every failure mode is a rejection,\nnever a synchronous throw.\n\n## TypeScript\n\nThe result type comes from the attempt, so nothing needs annotating in the common case:\n\n```ts\nconst user = await retryRequest(async () => ({ id: 1, name: \"Ada\" }));\n//    ^? { id: number; name: string }\n\nconst raw = await retryRequest<User>(() => fetch(url).then((r) => r.json()));\n//    ^? User — the explicit parameter types an otherwise `any` json()\n```\n\nThe error handed to `shouldRetry` and `onRetry` is `unknown`, because that is what a `catch` gives\nyou. `RetryOptions` is exported for wrappers that pass options through, and every field on it is\noptional:\n\n```ts\nconst HOUSE_STYLE: RetryOptions = {\n  maxRetries: 3,\n  retryBaseDelay: 400,\n  maxDelay: 10_000,\n};\n\nexport const withRetry = <T>(fn: () => Promise<T>, options?: RetryOptions) =>\n  retryRequest(fn, { ...HOUSE_STYLE, ...options });\n```\n\n## Exports\n\n`retryRequest` · `RetryOptions` — that is the entire surface.\n\n## License\n\nMIT\n","readmeFilename":"README.md"}