{"_id":"@bakidev/async-caller","name":"@bakidev/async-caller","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"name":"@bakidev/async-caller","version":"1.0.0","description":"Async call wrapper with retry, concurrency limiting, and 429/Retry-After rate limiting on top of a token bucket.","keywords":["async","concurrency","retry","rate-limiting","retry-after","429","backoff","type safety","typescript"],"license":"MIT","author":{"name":"Nurbaki Kasikci","url":"https://github.com/mnkasikci"},"homepage":"https://github.com/mnkasikci/async-caller#readme","repository":{"type":"git","url":"git+https://github.com/mnkasikci/async-caller.git"},"bugs":{"url":"https://github.com/mnkasikci/async-caller/issues"},"main":"lib/index.js","module":"lib/index.mjs","types":"lib/index.d.ts","engines":{"node":">=20"},"scripts":{"typecheck":"tsc --noEmit","build":"tsup","lint":"eslint .","lint:fix":"eslint . --fix","test":"vitest run","test:watch":"vitest","prepublishOnly":"npm run typecheck && npm run test && npm run build","release":"npm publish --tag latest"},"devDependencies":{"@types/node":"^20.4.2","@vitest/coverage-v8":"^4.1.10","changelogen":"^0.6.2","eslint":"^8.50.0","eslint-config-standard-with-typescript":"^39.1.0","eslint-plugin-import":"^2.28.1","eslint-plugin-n":"^16.1.0","eslint-plugin-promise":"^6.1.1","tsup":"^8.0.2","typescript":"^5.2.2","vitest":"^4.1.10"},"dependencies":{"@bakidev/token-bucket":"^1.0.0"},"publishConfig":{"access":"public"},"gitHead":"41afd8a32206c42115fc687c62a98f3422c84195","_id":"@bakidev/async-caller@1.0.0","_nodeVersion":"25.2.1","_npmVersion":"11.6.2","dist":{"integrity":"sha512-r6RFY5tmAYWfXHXIXiq+Q9DvDp3F4KSp+/oMkOnkCL9WvjzuMFmP98RgHRs4InKLjXmjclZhI+M3EYs+9K5wew==","shasum":"59737179fd31e2a9db2aa30c301f9c7bee57bcd7","tarball":"https://registry.npmjs.org/@bakidev/async-caller/-/async-caller-1.0.0.tgz","fileCount":11,"unpackedSize":144412,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDmJX9gxtb3u4AGgt7I8ikuUhZFuHpYIG811G7/Q7vtJQIhAO9pwdmV7T0rq29BxA95Y4sHAamwku34hkObWgfkQ4BY"}]},"_npmUser":{"name":"mnkasikci93","email":"mnkasikci@gmail.com"},"directories":{},"maintainers":[{"name":"mnkasikci93","email":"mnkasikci@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/async-caller_1.0.0_1784591306859_0.9037377280913721"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-20T23:48:26.686Z","1.0.0":"2026-07-20T23:48:27.007Z","modified":"2026-07-20T23:48:27.233Z"},"maintainers":[{"name":"mnkasikci93","email":"mnkasikci@gmail.com"}],"description":"Async call wrapper with retry, concurrency limiting, and 429/Retry-After rate limiting on top of a token bucket.","homepage":"https://github.com/mnkasikci/async-caller#readme","keywords":["async","concurrency","retry","rate-limiting","retry-after","429","backoff","type safety","typescript"],"repository":{"type":"git","url":"git+https://github.com/mnkasikci/async-caller.git"},"author":{"name":"Nurbaki Kasikci","url":"https://github.com/mnkasikci"},"bugs":{"url":"https://github.com/mnkasikci/async-caller/issues"},"license":"MIT","readme":"# @bakidev/async-caller\n\nAsyncCaller is a TypeScript library for making asynchronous calls with retry, concurrency, and rate limiting capabilities.\nIt utilizes TokenBucket module that you can find at \"https://www.npmjs.com/package/@bakidev/token-bucket\".\n\n## Features\n\n- **Rate Limiting**: Control the rate of requests using a token bucket algorithm.\n- **Retry Mechanism**: Automatically retry failed requests with customizable retry options.\n- **Automatic Check For 429 Erros**:If the function sent to the async caller is a function called with fetch(), it automatically checks the incoming headers for 429 and make the necessary adjustments to the tokenbucket of the caller accordingly if there is a 429 error.\n  The delay amount is determined according to \"Retry-After\" header of the response. It works with Headers object used in modern fetch API and also plain objects. If there is no \"Retry-After\" to be found then default formula is used to calculate the delay amount by using backoffFactor of retryOptions (check Configuration Options - RetryOptions below).\n- **Other Errors**: If the function sent to the async caller is fetch, it does not retry with error codes between 400 and 499. (except 429)\n- **Concurrency Control**: Limit the number of concurrent tasks.\n- **Type Safety**: Ensures type-safe responses when using `fetch`. If function using fetch is typesafe, then asyncCaller also returns typesafe value.\n\n## Installation\n\nTo install the package, use npm or yarn:\n\n```sh\nnpm install @bakidev/async-caller\n```\n\nor\n\n```sh\nyarn add @bakidev/async-caller\n```\n\n## Usage\n\n### Quick Start\n\n```typescript\nimport { AsyncCaller } from '@bakidev/async-caller';\n\n// AsyncCaller constructor takes two optional parameters, tokenBucketOptions and retryOptions\n// If they are not given as parameters, the default values (defined in the module) will be used.\n\nconst asyncCaller = new AsyncCaller();\n\nasync function fetchData() {\n  // Your async function logic\n}\n\nasyncCaller\n  .call(fetchData)\n  .then((result) => console.log(result))\n  .catch((error) => console.error(error));\n```\n\n### Set the tokenBucketOptions\n\n```typescript\nimport { AsyncCaller } from '@bakidev/async-caller';\n\n// Here we only specify tokenBucketOptions.\nconst asyncCaller = new AsyncCaller({\n  tokenBucketOptions: {\n    capacity: 10,\n    fillPerWindow: 10,\n    windowInMs: 1000,\n  },\n});\n\nasync function fetchData() {\n  // Your async function logic\n}\n\nasyncCaller\n  .call(fetchData)\n  .then((result) => console.log(result))\n  .catch((error) => console.error(error));\n```\n\n### Set the retryOptions\n\n```typescript\n// Instead of default options for retry we can set them ourselves.\n\nimport { AsyncCaller } from '@bakidev/async-caller';\n\nconst asyncCaller = new AsyncCaller({\n  tokenBucketOptions: {\n    capacity: 20,\n    fillPerWindow: 100,\n    windowInMs: 60000,\n  },\n  retryOptions: {\n    maxRetries: 5,\n    minDelayInMs: 500,\n    maxDelayInMs: 20000,\n    backoffFactor: 2,\n  },\n  concurrency: 10,\n});\n\nasync function fetchData() {\n  // Your async function logic\n}\n\nasyncCaller\n  .call(fetchData)\n  .then((result) => console.log(result))\n  .catch((error) => console.error(error));\n```\n\n### Sending All Requests At Once\n\n```typescript\n/*\nWith the help of async caller, you can completely transfer the rate limiting issue to the async caller function and\nsend all requests at once with await Promise.all.\nasync caller works in accordance with the given limits.\nThanks to the async caller, your site can be used as quickly as possible within the specified rate limits.\n*/\n// Without async caller\n  for (const user of users) {\n    const userData = await fetch(https://www.somewebsite.com/fetchuserinfo/${user.id});\n    // do something with userData\n  }\n  // too slow, each request has to wait the previous one to complete.\n\n  // Without async caller\n  await Promise.all(users.map(async user => {\n    const userData = await fetch(https://www.somewebsite.com/fetchuserinfo/${user.id});\n    // do something with userData\n  }));\n  // sends all of them together, but will probably get rate limited.\n\n  // with async caller\n  const asyncCaller = new AsyncCaller({\n    tokenBucketOptions: {\n      capacity: 10,\n      fillPerWindow: 10,\n      windowInMs: 1000,\n    },\n  });\n  await Promise.all(users.map(async user => {\n    const userData = await asyncCaller.call(async () => fetch(https://www.somewebsite.com/fetchuserinfo/${user.id}));\n    // do something with userData\n  }));\n\n```\n\n### Verbose Logging\n\nWhen verbose logging is enabled, you will see detailed logs about the internal operations of the `AsyncCaller`. For example:\n\n```plaintext\nAsyncCaller: Running task... Concurrency: (1 / 10) (Queue length: 0)\nAsyncCaller: Too many requests detected.\nAsyncCaller: Max retries exceeded. Rejecting...\n```\n\n## Configuration Options\n\n### TokenBucketOptions\n\n- **capacity**: The maximum number of requests allowed in a window.\n- **fillPerWindow**: The number of requests to allow per window. This determines the rate at which requests are allowed.\n- **windowInMs**: The size of the window in milliseconds.\n- **initialTokens**: The initial number of allowed requests. If not provided, it defaults to the capacity.\n\n### RetryOptions\n\n- **maxRetries**: The maximum number of retries. Default is 3. The maxRetries is the number of the extra tries.\n  For example if maxRetries is set to 10, the total number of tries would be 11 with the first try.\n- **minDelayInMs**: The minimum delay between retries in milliseconds. Default is 1000.\n- **maxDelayInMs**: The maximum delay between retries in milliseconds. Default is 10000.\n- **backoffFactor**: The factor by which the delay should be increased after each retry. Default is 2.\n\n```typescript\n/* In this example maxRetries is set to 1. It means that in total it will be tried two times (i.e. once for first try, once for retry.\n  The minimum delay between retries (i.e. minDelayInMs) is set to 100 milliseconds.\n  The maximum delay between retries (i.e. maxDelayInMs) is set to 10000 milliseconds.\n  backoffFactor is set to 3, so the delay will be increased 3 times after each retry.\n*/\nimport { AsyncCaller } from '@bakidev/async-caller';\n\nconst asyncCaller = new AsyncCaller({\n  retryOptions: {\n    maxRetries: 1,\n    minDelayInMs: 100,\n    maxDelayInMs: 10000,\n    backoffFactor: 3,\n  },\n});\n```\n\n### Concurrency\n\n- **concurrency**: The maximum number of concurrent tasks allowed. Default is 5.\n\n```typescript\n/* concurrency is set to 10. So maximum 10 tasks can be handles concurrently.\n */\nimport { AsyncCaller } from '@bakidev/async-caller';\n\nconst asyncCaller = new AsyncCaller({\n  retryOptions: {\n    maxRetries: 10,\n    minDelayInMs: 300,\n    maxDelayInMs: 15000,\n    backoffFactor: 2,\n  },\n  concurrency: 10,\n});\n```\n### Safety margin\n\n- **safetyMarginMs**: Milliseconds added to `tokenBucketOptions.windowInMs` before it is handed to the token bucket. Use it to compensate for timer drift so your configured rate is never *exceeded* upstream. Defaults to `0` and is applied **uniformly** whether or not you pass `tokenBucketOptions`.\n\n```typescript\nimport { AsyncCaller } from '@bakidev/async-caller';\n\n// Treat the window as 110ms internally to stay comfortably under a 10 req/s limit.\nconst asyncCaller = new AsyncCaller({\n  tokenBucketOptions: { capacity: 10, fillPerWindow: 10, windowInMs: 100 },\n  safetyMarginMs: 10,\n});\n```\n\n### Error-code classification\n\n- **treatErrorCodeAsStatus**: When `true`, a numeric `error.code` is considered when extracting HTTP status codes. Defaults to `false` — non-HTTP numeric codes (gRPC status codes, some DB drivers) can fall in the 400–499 range and be misclassified as a non-retryable client error. Only enable it if your errors put a real HTTP status in `code`.\n\n## The `fn` contract: idempotent, rebuilds its own request\n\n`call(fn)` **re-invokes `fn()` from scratch on every attempt.** `fn` must therefore be idempotent and construct a *new* request each time it runs. A closure over an already-consumed stream — a `Request`/`Response` body, a Node stream, or `FormData` carrying a file stream — will fail the second attempt with a confusing \"body already used\" error that looks nothing like a retry problem.\n\n```typescript\n// ✅ Correct — a fresh request is built on every invocation.\nawait asyncCaller.call(() => fetch('https://api.example.com/things', {\n  method: 'POST',\n  body: JSON.stringify(payload),\n}));\n\n// ❌ Wrong — the Request is built once and its body is consumed on the first try.\nconst req = new Request('https://api.example.com/things', { method: 'POST', body: JSON.stringify(payload) });\nawait asyncCaller.call(() => fetch(req)); // second attempt: \"body already used\"\n```\n\n## Module-scope construction vs. use (Cloudflare Workers / Durable Objects)\n\nAn `AsyncCaller` owns a `TokenBucket`, whose timers are not permitted at module scope in the Workers runtime. The rule:\n\n> An `AsyncCaller` may be **constructed** at module scope. It must not be **used** there. All `call()` work belongs inside a request handler.\n\n```typescript\n// module scope — construction only\nconst caller = new AsyncCaller({ tokenBucketOptions: { capacity: 10, fillPerWindow: 10, windowInMs: 1000 } });\n\nexport default {\n  async fetch(request, env) {\n    // use it here, inside the handler\n    const data = await caller.call(() => fetch('https://api.example.com/data'));\n    return new Response(await data.text());\n  },\n};\n```\n\n## Body-encoded rate limits and errors: `CallHooks`\n\nThe built-in classifier keys off HTTP status codes only. Some APIs return a `200` whose success or rate-limit status lives in the body, e.g. `{\"error\":{\"status\":\"RESOURCE_EXHAUSTED\",\"retryAfter\":50}}` or `{\"success\":false}`. `CallHooks` give that logic a declared home instead of every call site reinventing it.\n\n```typescript\nimport { AsyncCaller, type CallHooks } from '@bakidev/async-caller';\n\nconst hooks: CallHooks<{ ok: boolean; retryAfterMs?: number }> = {\n  // Non-null ⇒ treat exactly as an HTTP 429 with this delay: global backpressure + retry.\n  rateLimit: (body) => (body.retryAfterMs ? { retryAfterMs: body.retryAfterMs } : null),\n  // Non-null ⇒ treat exactly as if fn() threw: retried per its own non-retryable marking,\n  // with NO effect on other callers.\n  error: (body) => (body.ok ? null : new Error('request failed')),\n};\n\n// Per-call:\nawait asyncCaller.call(fetchThing, hooks);\n\n// Or as a client-level default (per-call hooks still take precedence):\nconst asyncCaller = new AsyncCaller({ hooks });\n```\n\nTwo semantics, deliberately distinct:\n\n> A **rate limit** throttles every caller (global backpressure on the shared bucket). An **error** retries only the call that failed.\n\n**Resolution is most-specific-first, falling through on `null`:**\n\n1. per-call hook (passed to `call`)\n2. client-level hook (passed to the constructor)\n3. the built-in HTTP `429` + `Retry-After` check\n\nLayer 3 is **unconditional** — a real HTTP `429` is a rate limit no matter what the hooks return, so overriding `rateLimit` for one odd endpoint never silently disables genuine header-based 429 handling. Return `null` from a hook to fall through to the next layer.\n\n> Note: a hook receives the already-resolved result (whatever `fn` returned), not a `Response` + parsed `body` — `AsyncCaller` never performs the fetch itself. If you need the body, have `fn` return it (or `{ res, body }`).\n\n### `retryAfterMs`\n\n`Retry-After` header parsing (integer seconds **and** HTTP-date, `Headers` object **and** plain object) is preserved for the built-in path. But a body-derived delay is already a number, so `rateLimit` returns `retryAfterMs` directly — it is used as-is, with no round-trip through a stringified-seconds representation.\n\n## Authors\nNurbaki Kasikci - [GitHub](https://github.com/mnkasikci)  - [Twitter](https://twitter.com/mnkasikci)\n\n## Credits\n\nThis package began as a fork of an async caller I built while working at\n[Grape Law Firm](https://github.com/grape-law-firm), which was also published as\n[@grapelaw/async-caller](https://www.npmjs.com/package/@grapelaw/async-caller)\n([source](https://github.com/grape-law-firm/async-caller)). It is republished\nhere with their permission. This version adds bug fixes and full test coverage.\nThe original work is MIT-licensed (Copyright © 2024 Grape Law Firm); that notice\nis retained alongside mine in [LICENSE](LICENSE).\n\n## Contribution\nWe welcome contributions to improve this package and encourage users to submit bug reports, feature requests, or any other contributions that can enhance the project. Please follow the guidelines below to contribute:\n1. Report Issues: If you encounter any issues or have suggestions for improvements, please open an issue on [GitHub](https://github.com/mnkasikci/async-caller/issues) \n2. Pull Requests: You are welcome to [submit Pull Requests](https://github.com/mnkasikci/async-caller/pulls) (PRs) for bug fixes or new features. Make sure to follow the established coding conventions and explain the purpose of your changes. \n","readmeFilename":"README.md","_rev":"1-fbca39697db380ed9f0d8dd7091ca23b"}