@mondart/nestjs-common-module-bottleneck
v3.2.17
Published
Bottleneck throttling and queue utilities for NestJS.
Readme
@mondart/nestjs-common-module-bottleneck
Per-key request throttling and queuing for NestJS, built on top of the
bottleneck library: a
BottleneckService for scheduling arbitrary work through a keyed limiter
group (optionally backed by Redis so limits are shared across instances), an
HTTP interceptor that throttles routes per resolved request key, and
CQRS bus wrappers that queue and deduplicate in-flight commands/queries.
Registration
import { BottleneckModule } from '@mondart/nestjs-common-module-bottleneck';
@Module({
imports: [
BottleneckModule.register({
appName: 'my-service',
basePath: 'orders',
requestKey: (req) =>
req.params?.orderId
? { key: 'orderId', value: req.params.orderId }
: null,
useGlobal: false, // routes must opt in via @UseBottleneckDecorator()
maxConcurrent: 1,
minTime: 0,
}),
],
})
export class AppModule {}Or asynchronously:
BottleneckModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
appName: config.get('APP_NAME'),
requestKey: (req) => ({ key: 'orderId', value: req.params.orderId }),
}),
});BottleneckModule is @Global(), so BottleneckService, QueuedCommandBus,
and QueuedQueryBus are available for injection anywhere without
re-importing the module. It also installs BottleneckInterceptor as a
global APP_INTERCEPTOR.
To share limits across instances instead of throttling each process
independently, set useRedis: true and provide redis: { host, port, ... }
— it's passed straight through to Bottleneck's ioredis datastore.
When Redis is unreachable
Throttling is best-effort; the work is not. If a limiter fails because Redis
cannot be reached (ECONNREFUSED, a closed connection, retries exhausted) or
because it answered but cannot serve the write (READONLY during a failover,
LOADING, OOM, CLUSTERDOWN), the task runs unthrottled instead of
failing the caller, and Redis is skipped entirely for the next 5 seconds so an
outage costs one timeout rather than one per request. Each window opens with
an ERROR log naming the cause. Limits are not enforced while it is open — if
a route must fail closed instead, throttle it outside this service.
Commands default to maxRetriesPerRequest: 2 (ioredis' own default of 20
leaves a request hanging ~30s before it fails); override it with
redis.maxRetriesPerRequest.
A limiter whose Redis state went stale — SETTINGS_KEY_NOT_FOUND when its
settings key expired, UNKNOWN_CLIENT when another replica swept its client —
is dropped and rebuilt once, then the task is retried. The retry cannot
duplicate the callback: the script that fails runs before the job is
registered.
Job expiration
Every job gets expiration: 60000 by default. This is not optional
bookkeeping: Bottleneck reclaims a slot for a dead client only through
job_expirations (register.lua), and its unresponsive-client sweep skips
any client whose running weight is still above zero (process_tick.lua). So
without an expiration, a process killed mid-job (SIGKILL, OOM, crash) never
releases its slot, and with maxConcurrent: 1 that key stays wedged for
every replica — indefinitely, because each new request refreshes the key's
TTL.
Set expiration above the slowest throttled handler, or null to disable it.
A job that overruns is rejected with a BottleneckError and its slot is
freed; the handler itself keeps running, Bottleneck just stops waiting for it.
Set it through this module's options or @ConfigBottleneckDecorator(), never
through raw Bottleneck ConstructorOptions — Bottleneck reads expiration
only from its job defaults, so it is silently ignored there.
Throttling HTTP routes
BottleneckInterceptor runs on every HTTP request but only queues one that
opts in — nothing is throttled by default.
import {
UseBottleneckDecorator,
SkipBottleneckDecorator,
ConfigBottleneckDecorator,
} from '@mondart/nestjs-common-module-bottleneck';
@UseBottleneckDecorator()
@Patch(':orderId')
updateOrder() { ... }
@SkipBottleneckDecorator()
@Get(':orderId')
getOrder() { ... } // never throttled, even if useGlobal is set
@UseBottleneckDecorator()
@ConfigBottleneckDecorator({ maxConcurrent: 2, minTime: 500 })
@Post(':orderId/items')
addItem() { ... } // overrides the module-level limiter settings for this routeThe queue key is built from basePath and whatever requestKey(req)
resolves — e.g. orders/orderId:42, so all requests for the same order
serialize through the same limiter. If requestKey returns null/undefined
for a request that's otherwise opted in, the interceptor throws a
ServiceUnavailableException. If the per-key queue is already full
(maxQueueSize: 100), Bottleneck's rejection is also turned into a
ServiceUnavailableException instead of an unhandled error.
BottleneckService
Use this directly to throttle work that isn't an HTTP request (Kafka handlers, cron jobs, etc.).
constructor(private readonly bottleneckService: BottleneckService) {}
// Queue explicitly under a key, without in-flight caching:
await this.bottleneckService.scheduleForKey('order:42', () => doWork());
// Queue and, for the duration the task is pending, share the result with
// any other caller that uses the same key:
const result = await this.bottleneckService.runQueuedTask('order:42', () =>
fetchAndProcess(),
);Queued CQRS buses
QueuedCommandBus and QueuedQueryBus are drop-in replacements for
@nestjs/cqrs's CommandBus/QueryBus. Each execute() call is routed
through BottleneckService.runQueuedTask using a key derived from the
command/query's constructor name and its serialized fields, so two
identical commands dispatched concurrently share one execution instead of
running twice.
constructor(private readonly queuedCommandBus: QueuedCommandBus) {}
await this.queuedCommandBus.execute(new UpdateOrderCommand(orderId, dto));