@dianemo/core
v2.1.0
Published
Distribute one API rate limit across every process that spends it — cost-weighted priority queues, freeze/thaw recovery and pluggable backends
Readme
A host for outbound HTTP clients that share a rate limit across processes.
Most rate limiters assume one process. Run four replicas behind a load balancer and each independently believes it owns the vendor's full budget, so you exhaust the limit at 4× the rate you configured. Dianemo coordinates through a shared backend, so N replicas draw from one budget.
Full documentation: github.com/vivo-us/dianemo
What it does that a per-process limiter doesn't
- Shared token buckets. One budget per credential, refilled lazily and drawn down by every replica through the backend. Adding replicas doesn't multiply your request rate.
- Several limits per client. A vendor publishing "20 a second and 50,000 a day" is one client with two limits, and a request goes out only when every one admits it — claimed in one atomic backend operation, so a decline never leaves the first budget spent.
- Rate-limit plans in templates. A template can offer a closed menu of limits — one per subscription tier — and each tenant picks one by key when its credentials are added.
- Cost-weighted priority queue. A request declares a
cost(how many tokens it consumes) and apriority, so a bulk backfill is admitted behind interactive traffic instead of starving it. - Freeze and thaw with single-flight probing. When a vendor returns a hard limit, the client freezes. Recovery is probed by exactly one request across the whole fleet, rather than every replica retrying into a closed door.
- Per-credential isolation. One tenant hitting their ceiling doesn't stall anyone else's traffic through the same integration.
- Leader election. One process drains each client's queue; the rest enqueue and await.
- Auth flows built in. OAuth2 client-credentials with automatic refresh, static tokens and basic auth, with credentials supplied separately from client definitions.
- OpenTelemetry spans carrying
dianemo.cost,dianemo.priorityandrate_limitedevents when@opentelemetry/apihas a tracer provider registered. A no-op otherwise.
Install
Dianemo ships the coordination logic in @dianemo/core and the shared state in a backend you
choose. Which one you need is decided by a single question: how many processes will run this?
# One process, and it will stay one process.
npm install @dianemo/core
# More than one process — a replica set, a worker pool, a pod that autoscales.
npm install @dianemo/core @dianemo/backend-redis ioredisESM only, Node 20+. ioredis is a peer dependency of the Redis backend because you pass your own
connection in; two copies in one process would coordinate against different clients.
@opentelemetry/api is a direct dependency of core. It is itself dependency-free and no-ops unless
your application registers a tracer provider, so it costs nothing if you do not use tracing.
Choosing a backend
This is the most consequential decision in the library, and it is not a performance tuning knob.
| | memoryBackend() | redisBackend(connection) |
| ------------------------------- | ----------------- | -------------------------- |
| Package | @dianemo/core | @dianemo/backend-redis |
| Dependencies | none | ioredis |
| Safe process count | exactly 1 | any |
| Budget shared between processes | no | yes |
| Survives a restart | no | yes |
| OAuth tokens shared | no | yes |
The memory backend keeps the budget in local variables, so every process that runs it gets its own private copy of every rate limit. Two processes configured for 100 requests a second will send 200; four will send 400. Nothing in dianemo can detect this, and the vendor will not warn you — the first signal is usually a 429 storm or a suspended key.
Use it when there is one process and there will stay one: a CLI, a cron job, a desktop app, a test
suite, a single-instance service. The moment a second process exists — a replica, a worker, a
second pod, a pm2 -i 2 — switch to a distributed backend. That is not an optimisation; it is the
only thing that makes the limit real. If you are unsure, use Redis: being wrong in that direction
costs one connection, and being wrong in the other direction costs your API access.
Valkey works unchanged, since it speaks the same protocol: redisBackend() takes its connection
as-is. DragonflyDB refuses some of the backend's scripts by default; see
the Redis backend page.
Quick start
import { Redis } from "ioredis";
import RequestHandler, {
buildClientName,
type TokenCredentials,
} from "@dianemo/core";
import { redisBackend } from "@dianemo/backend-redis";
// Template names are typed: declare each one with the credentials it takes.
declare module "@dianemo/core" {
interface ClientTemplates {
acme: TokenCredentials;
}
}
const handler = new RequestHandler({
key: process.env.REQUEST_HANDLER_KEY!, // 32+ characters; encrypts stored credentials
backend: redisBackend(new Redis(process.env.REDIS_URL!)),
logger: console, // optional; silent by default
});
// The shape of the integration — its rate limit, auth and base URL. No secrets.
await handler.registerClientTemplate("acme", (creds) => [
{
name: buildClientName("acme", creds),
rateLimit: [
{ type: "requestLimit", interval: 1000, tokensToAdd: 10, maxTokens: 10 },
],
authentication: { type: "token", token: creds.token }, // Authorization: Bearer …
requestOptions: { defaults: { baseURL: creds.baseUrl } },
},
]);
// Credentials arrive separately — from a vault, a database, or env.
await handler.addTemplateClient("acme", {
instanceId: "production",
baseUrl: "https://api.acme.example",
token: process.env.ACME_API_TOKEN!,
});
const orders = await handler.handleRequest({
clientName: "acme:_:production",
requestName: "acme.orders.list",
method: "GET",
url: "/orders",
});handler.start() is called automatically on the first request or template call, and is idempotent.
It registers SIGTERM/SIGINT/SIGHUP handlers so the queue drains on shutdown. After stop(),
requests are refused with ClientUnavailableError handler_stopped until start() is called
again.
For a single-process program, swap two lines and drop the dependency entirely:
import RequestHandler, { memoryBackend } from "@dianemo/core";
const handler = new RequestHandler({
key: process.env.REQUEST_HANDLER_KEY!,
backend: memoryBackend(), // single process only — see "Choosing a backend"
});Plugins and 2.0. Integrations are published one package per vendor from dianemo-plugins, but every one released so far is 1.x: it declares a peer of
@dianemo/core ^1.0.0and a rate-limit shape 2.0 refuses, so none of them works with core 2.0 yet. Nothing waits on them:registerClientTemplateplushandleRequest, as above, covers any vendor, and writing your own plugin needs only@dianemo/core.
Concepts
| Term | Meaning |
| ------------ | ------------------------------------------------------------------------------------------ |
| Template | The shape of an integration: rate limits, auth flow, sub-clients. Carries no credentials. |
| Client | A template plus one set of credentials, named <template>:<orgId \| _>:<alias>. |
| Plugin | A template plus the request functions that use it, packaged together. |
| Grant | An independently rate-limited credential within a client. |
Templates and credentials are deliberately separate. A template is code you ship; credentials are data that arrives at runtime, encrypted into the backend and broadcast to every replica. That separation is why a plugin never has to know whether its credentials came from a vault, a database, or an environment variable.
Sub-clients, grants, overrides, leader election and freeze/thaw
Errors
Every error dianemo raises itself carries statusCode and code alongside message, so an HTTP
layer can map them without importing this package. A failed upstream call is the exception: it
reaches a handleRequest caller as axios's own error, from the last attempt, which has the vendor's
answer on status rather than statusCode:
if (typeof err.statusCode === "number" && typeof err.code === "string") {
res
.status(err.statusCode)
.json({ error: err.code, error_description: err.message });
} else if (typeof err.status === "number") {
res
.status(502)
.json({ error: "upstream_error", upstream_status: err.status });
}Retries with backoff are automatic; what surfaces is what remained after them. getOriginalStatus(err)
and getOriginalResponseData(err) read the underlying vendor response off a RequestError, for
callers that want to turn a 404 into null.
Logging
Pass anything with debug/info/warn/error accepting (obj, msg) or (msg). A pino
instance satisfies this structurally. The default is silence: a library embedded in someone else's
process shouldn't claim their stdout uninvited.
Performance
Roughly 6–8% overhead over the same HTTP client at the same concurrency, measured against a loopback upstream so the figure reflects coordination cost rather than vendor latency. Uncontended requests skip the queue entirely, deciding admission in one atomic backend operation.
Methodology, scenarios and the full results
