🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

regulo

Package Overview
Dependencies
Maintainers
1
Versions
13
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

regulo

Control the heat — a priority-queue semaphore with weighted permits, a saturation circuit breaker, adaptive backoff, and built-in metrics. Zero dependencies.

latest
Source
npmnpm
Version
1.5.0
Version published
Weekly downloads
1.1K
4.27%
Maintainers
1
Weekly downloads
 
Created
Source

Regulo

🔥 Control the heat

npm version CI coverage minzipped size types license Socket

A concurrency limiter with a built-in circuit breaker, so the expensive parts of your system never boil over. Regulo is a priority-queue semaphore with weighted permits, a saturation circuit breaker, adaptive backoff, and built-in windowed metrics. Zero dependencies, ships ESM and CJS, runs on Node.js and other modern JavaScript runtimes.

Like the dial on a gas range, Regulo sits between incoming work and the burner. Most concurrency libraries just cap how many things run at once and stop there. Regulo is built for the case where that limit is protecting something expensive — SSR rendering, a database pool, a downstream API — and you need to watch the flame, send the important pots to the front, and turn things down cleanly before the system scorches.

Highlights

  • 🎛️ Bounded concurrency, with priority and weighting — set how many burners are lit, send important work to the front, and let one heavy job claim more than one burner.
  • 🛡️ Saturation circuit breaker — when work backs up faster than it clears, Regulo takes the pot off the heat: it opens the circuit and sheds load immediately, then probes for recovery and closes again on its own. (See How the circuit breaker works — it trips on saturation, not on your operation's errors.) The breaker is pluggable: feed it downstream errors with reportFailure(), or swap in a no-op breaker, a manual kill switch, or your own — see Circuit breakers.
  • 🌡️ Adaptive backoff — during a timeout burst, dispatch eases down to a simmer and returns to a full boil on its own once things recover.
  • 📈 Built-in observability — windowed 1m/5m/1h/24h rollups (throughput, latency, queue depth, in-flight), lifetime counters, and an event stream, all through one status() call.
  • ⏳ Head-of-line fairness — once a caller is in line, nobody jumps the queue ahead of it.
  • 🪶 Small footprint, no supply-chain surface — a single ~30 KB file with zero runtime dependencies, so there's nothing transitive to audit, update, or trust. Tree-shakeable ESM.
  • 🧯 Production-minded — graceful drain(), reset(), cancel(), and shutdown(); stale-task purging; double-release safety; strict-mode TypeScript types.

Install

npm install regulo

Requires Node.js >= 20 (or any runtime providing AbortSignal, queueMicrotask, and timers).

Quick start

import { Semaphore } from 'regulo';

const semaphore = new Semaphore(10); // 10 concurrent permits — ten burners

const result = await semaphore.use(async () => {
  return await expensiveOperation();
});

use() acquires a permit, runs your function, and releases the permit afterward — even if the function throws. The primary export is the Semaphore class; regulo is the dial wrapped around it.

Core concepts

Semaphore — holds a fixed pool of permits (the burners). Callers acquire a permit before doing work and release it when done. When all permits are held, callers queue until one frees up, or until their timeout fires.

Weighted permits — a single acquire can consume more than one permit (weight). Big pots need more burners, so heavier work reserves proportionally more of the pool.

Priority queue — queued callers are dispatched in ascending priority order (lower number = higher priority — the front burner). Default priority is 0. Dispatch is head-of-line fair: once any caller is queued, later callers always queue behind it rather than grabbing a free permit, so a lower-priority or lighter task can never jump ahead of a waiting higher-priority or heavier one.

Queue orderingqueueOrder selects a dispatch preset. Priority and arrival order are two independent axes. 'fifoWithPriority' (the default) and 'lifoWithPriority' keep priority as the primary sort key and break equal-priority ties earliest- or latest-enqueued first. 'fifo' and 'lifo' drop priority entirely and order purely by enqueue time. For full control, pass a comparator (lower sorts/dispatches first), which overrides queueOrder:

import { Semaphore, QUEUE_ORDERINGS } from 'regulo';

// Built-in preset — pure arrival order, priority ignored
const a = new Semaphore(4, { queueOrder: 'lifo' });

// Custom: priority first, then lightest work first, then FIFO. The receiver is a
// read-only QueuedTaskView ({ id, priority, enqueueTime, weight }); `id`
// increases with enqueue order. Probe tasks are always dispatched first
// regardless of your comparator, so you never need to handle them.
const b = new Semaphore(4, {
  comparator: (x, y) => (x.priority - y.priority) || (x.weight - y.weight) || (x.id - y.id),
});

// Compose with a preset
const c = new Semaphore(4, { comparator: QUEUE_ORDERINGS.lifoWithPriority });

See Choosing an ordering for how the ordering interacts with weighted permits and head-of-line dispatch — the choice has real throughput consequences under mixed weights.

Circuit breaker — watches for saturation and takes the pot off the heat when the system can't keep up. See the dedicated section below for exactly what it measures.

Backoff — exponential backoff eases dispatch down to a simmer during sustained timeout bursts. The delay grows on each timeout and decays continuously over time, throttling dispatch while downstream systems recover and returning to zero on its own once the burst subsides. The current delay is exposed in status() and TASKTIMEOUT events.

Choosing an Ordering and Its Implications

Two facts are true of every ordering, because they live in the scheduler, not the comparator:

  • Dispatch follows the configured order strictly. Whatever sorts to the head dispatches next; nothing behind it jumps ahead (head-of-line fairness).
  • The scheduler will not dispatch past a head that doesn't fit. With weighted permits, if the head needs more permits than are currently free, the scheduler waits for capacity to accumulate rather than skipping to a lighter task behind it. A free permit can therefore sit idle while the head waits. This prevents a lighter/lower-priority task from starving a heavier/higher-priority one — but under a wide weight distribution it can also stall throughput while the head waits.

That second rule is where the comparator choice matters: the rule is fixed, but which task ends up at the head — and therefore how often the stall bites — is entirely up to your ordering.

queueOrderDispatches firstHead-of-line stall exposure under mixed weights
fifoWithPriority (default)lowest priority value, ties earliest-firstA heavy task only holds the line while it is genuinely the highest-priority (or earliest equal-priority) waiter
lifoWithPrioritylowest priority value, ties latest-firstSame, but equal-priority ties favor the newest
fifoearliest enqueued (priority ignored)Classic head-of-line: whoever arrived first holds the line until it fits
lifolatest enqueued (priority ignored)The newest arrival holds the line until it fits
custom, heaviest-firstheaviest weightWorst case — deliberately parks the heaviest task at the head, maximizing the stall
custom, lightest-firstlightest weightMinimizes stalls — light work drains while capacity accumulates for heavy work

If you mix many light acquires with a few heavy ones and care about throughput, prefer a lightest-first tiebreaker (e.g. (a, b) => (a.priority - b.priority) || (a.weight - b.weight) || (a.id - b.id)), or give each weight class its own Semaphore so a heavy head in one pool can't stall the other. Conversely, if you must not let light work starve heavy work, the default's strict behavior is what you want.

Weight is a cost multiplier, not a task-type selector. weight exists to say "this unit of work costs N burners," for work drawing on the same resource pool and same failure domain. Don't use weight (or priority) to multiplex unrelated task types or downstreams through one Semaphore — the breaker and backoff are shared across everything in the instance (see Caveats). Use one Semaphore per resource instead.

How the Circuit Breaker Works

The breaker is a saturation breaker, not a fault breaker. It watches the rate of queue-acquisition timeouts — callers that waited longer than queueMaxTimeout for a permit — over a sliding window. When that rate crosses circuitBreakerThreshold (and the minimum count guards are met), the circuit opens and new requests are rejected immediately with CIRCUIT_OPEN. Tasks already waiting in the queue are evicted at that same moment and rejected with CIRCUIT_OPEN too — they would otherwise sit out their full queueMaxTimeout only to surface a misleading TIMEOUT. Each eviction emits a QUEUEEVICT event and increments the totalEvictions lifetime counter. After the cooldown elapses, one probe request is allowed through; if it succeeds the circuit closes, if it times out the circuit re-opens and the cooldown restarts.

What this means in practice:

  • The breaker trips when work backs up faster than permits free — the signature of a saturated or slow downstream. This is what protects the pool: it pulls everything off the heat before the pot boils over.
  • The breaker does not trip on errors thrown by the function you run inside use(). If your operation fails fast, the permit is released normally and the failure never reaches the breaker.

If you also need to trip on downstream errors (not just saturation), call reportFailure() with your own failure signal — the same breaker window applies — or swap the breaker entirely via Circuit breakers.

Recipes

For worked examples — Express middleware, priority lanes, weighted permits, breaker wiring, graceful shutdown, and more — see RECIPES.md.

API Reference

new Semaphore(count, config?)

Creates a semaphore with count permits.

acquire(abortSignal?, priority?, weight?): Promise<() => void>

Acquires a permit. Returns a release closure. Queues if no permit is available.

  • priority — Dispatch priority (any finite number; lower dispatches first). Defaults to 0. Non-finite values (NaN, Infinity) reject with INVALID_PRIORITY.
  • weight — Permits to consume (integer in 1..count). Defaults to 1. Invalid weights reject with INVALID_WEIGHT.

const release = await semaphore.acquire(abortController.signal, 1, 2); // priority 1, weight 2
try {
  await doWork();
} finally {
  release();
}

use<T>(fn, abortSignal?, priority?, weight?, onSettle?): Promise<T>

Preferred entry point. Acquires a permit, runs fn(), and releases — always, even if fn throws. With circuitBreakerFailurePredicate configured, rejections from fn() that match the predicate count as breaker failures, and a matching failure on a probe re-opens the circuit instead of closing it — see Feeding downstream errors.

  • onSettle?: (durationMs: number, outcome: 'success' | 'error') => void — reports how long fn() itself took (not queue-wait time) and whether it resolved or rejected. Never called if the acquire itself is rejected (no permit means fn() never ran). A throwing hook is caught and logged via console.warn — it never masks fn()'s own result. This is the hook to feed a per-operation latency histogram or SLO tracker; queue-wait latency is already in status().metrics.
await semaphore.use(
  () => callDownstream(),
  undefined, 0, 1,
  (durationMs, outcome) => histogram.observe({ outcome }, durationMs),
);

tryAcquire(weight?): (() => void) | null

Non-blocking. Returns a release closure, or null if insufficient permits are available or any tasks are already queued (head-of-line fairness — tryAcquire never jumps the queue).

  • weight — Permits to consume (integer in 1..count). Defaults to 1. Invalid weights return null.

drain(timeoutMs?): Promise<void>

Resolves when the queue is empty and all permits are returned. Multiple callers share the same promise — if a drain() is already in flight, later calls return that same promise as-is, so only the first caller's timeoutMs (if any) governs; a later caller's own timeoutMs argument is not applied. Pass timeoutMs (a positive integer) to set a deadline — rejects with TIMEOUT if not idle in time; an invalid value throws SemaphoreError (INVALID_ARGUMENT) synchronously, even if it turns out an in-flight drain's promise is what gets returned. Calling drain() after shutdown() rejects with SHUTDOWN.

Without timeoutMs, drain() can block indefinitely if a caller holds a permit and never releases it.

reset(options?): void

Rejects all queued tasks (SHUTDOWN) and restores the semaphore to its initial state, so it can be reused. Event listeners are preserved unless { clearListeners: true } is passed. Throws SemaphoreError (SHUTDOWN) if called after shutdown() — a shut-down instance is terminal and cannot be revived.

cancel(): void

Rejects all currently queued tasks with CANCELLED. In-flight permits are unaffected and the semaphore remains fully operational (unlike shutdown()).

reportFailure(): void

Feeds an external failure signal (e.g. a downstream error) into the circuit breaker: records one failure and evaluates the trip condition. A trip behaves exactly like a saturation trip — queued tasks are evicted with CIRCUIT_OPEN and CIRCUITOPEN fires with reason: 'reported-failure'. Only influences trip decisions while the circuit is closed; no-op after shutdown(). For use()-based workloads, circuitBreakerFailurePredicate automates this and additionally makes probes fault-aware. See Feeding downstream errors.

shutdown(reason?): void

Permanently stops the semaphore — kills the gas. All queued tasks are rejected, the purge interval is cleared, and metrics collection stops (status().metrics returns null afterwards; the collector's buffers are released). Outstanding release closures are invalidated and the permit pool is settled, so a release() arriving after shutdown is a safe no-op and post-shutdown status() reads as terminal. This is terminal: it cannot be reversed, and a later reset() on a shut-down instance throws rather than reviving it. Calling shutdown() again is a no-op.

on(event, listener) / off(event, listener) / removeAllListeners(event?)

Standard event emitter interface. Listeners are fully typed — the payload type is inferred from the event, so on(SemaphoreEvents.TASKACQUIRE, p => …) gives p the correct shape with no any. See Events reference below.

status()

Returns a snapshot of current operating state. See Metrics for the full shape.

status() is O(1) in queue depth — safe to call on a metrics scrape path. (Queue age is read from an enqueue-ordered index, not by scanning the queue.)

peekQueue(options?): QueuedTaskView[]

Read-only snapshot of the queue, in enqueue order. Entries additionally carry isProbe, so a circuit-breaker probe is identifiable in the view.

  • options.offset?: number — skip this many queued tasks before collecting. Default 0.
  • options.limit?: number — collect at most this many tasks after the offset. Default unbounded.

For a very deep queue, an admin-debug endpoint should page through it instead of always materializing the full array:

app.get('/admin/semaphore/queue', (req, res) => {
  const offset = Number(req.query.offset ?? 0);
  res.json(semaphore.peekQueue({ offset, limit: 50 }));
});

isAvailable(): boolean

Returns true if the semaphore is not shut down, the circuit is not open, and a permit is available. This is a capacity signal, not a dispatch guarantee: tasks may still be queued ahead (head-of-line fairness), in which case tryAcquire() returns null even while isAvailable() is true.

queueLength: number

Current number of tasks waiting for a permit.

availablePermits: number

Number of permits not currently held.

capacity: number

Total permits the semaphore was constructed with.

circuitState: 'closed' | 'open' | 'probing'

Current circuit breaker state.

Keyed Semaphore

KeyedSemaphore is a lazily-populated registry of one Semaphore per key — the one-Semaphore-per-resource pattern without hand-rolled Map bookkeeping. Every key shares the same (count, config); the first access constructs that key's Semaphore, later accesses return the same instance.

import { KeyedSemaphore } from 'regulo';
import { S3 } from '@aws-sdk/client-s3';

const buckets = new KeyedSemaphore(4); // 4 permits per bucket
const client = new S3({});

const fetchFromBucket = (bucket: string, key: string) =>
  buckets.use(bucket, () => client.getObject({ Bucket: bucket, Key: key }));

await Promise.all([
  fetchFromBucket('bucket-a', 'file-1'),
  fetchFromBucket('bucket-a', 'file-2'),
  fetchFromBucket('bucket-b', 'file-1'), // its own 4-permit pool, own breaker
]);

new KeyedSemaphore(count, config?)

Same arguments as Semaphore. Construction does not validate them — there is nothing to construct yet; the first forKey()/use() call constructs the underlying Semaphore and surfaces any INVALID_ARGUMENT then.

forKey(key: ID): Semaphore

Returns key's Semaphore, constructing it (with the registry's shared count/config) on first access. ID is string | number.

use<T>(key, fn, abortSignal?, priority?, weight?): Promise<T>

Sugar for forKey(key).use(fn, abortSignal, priority, weight).

has(key): boolean

true if key already has a constructed Semaphore — does not create one.

delete(key): boolean

Shuts down and forgets key's Semaphore, releasing its purge-interval timer. A later forKey(key) constructs a fresh one. Returns false if key had no Semaphore.

shutdown(reason?): void

Shuts down every key's Semaphore and empties the registry. Terminal, like Semaphore.shutdown().

size: number / keys(): IterableIterator<ID>

Number of keys with a live Semaphore, and an iterator over them.

Bounded key space only. There's no TTL or eviction — a key's Semaphore (and its purge-interval timer) lives until delete()/shutdown(). Use KeyedSemaphore for a small, known set of keys (per-downstream, per-shard, per-tenant from a bounded list), not a high-cardinality or unbounded one (e.g. one key per end user) — that leaks a Semaphore per key.

Configuration Reference

OptionTypeDefaultDescription
queueMaxLengthnumber1024Max tasks that may wait in the queue; further acquires reject with QUEUE_FULL. Positive integer; pass Number.MAX_SAFE_INTEGER for an effectively unbounded queue
queueMaxTimeoutnumber10000ms a queued task waits before TIMEOUT
queueMaxAgenumber30000ms before the purge interval ejects a task regardless of its own timeout
rejectOnFullbooleanfalseReject immediately when all permits are held (no queuing)
circuitBreakerThresholdnumber0.5Failure rate in (0,1) that trips the circuit
circuitBreakerWindownumber10000Sliding window size in ms for the failure rate. Min: 1000
circuitBreakerWindowBucketWidthnumber1000Width (ms) of each bucket in the circuit breaker's sliding window; bucket count = window / windowBucketWidth. Min: 1
circuitBreakerCooldownnumber5000ms the circuit stays open before allowing a probe. Min: 1000
circuitBreakerMinThroughputnumber10Min requests in window before circuit can trip
circuitBreakerMinFailuresnumber5Min failures in window before circuit can trip
circuitBreakerCircuitBreakerStrategyA breaker instance to use instead of the built-in saturation breaker; overrides all circuitBreaker* options. See Circuit breakers
circuitBreakerFailurePredicate(error: unknown) => booleanWhen set, use() counts matching rejections as breaker failures and probes become fault-aware. Must not throw. See Feeding downstream errors
backoffInitialTimeoutnumber50Initial backoff delay (ms) applied to scheduler wakeup on first timeout
backoffMaxTimeoutnumber2000Max backoff delay (ms) applied to scheduler wakeup
backoffDecayFactornumber0.5Backoff decay factor per idle second, in (0,1)
purgeIntervalMsnumber3000ms between stale-task purge sweeps. Min: 500
metricsEnabledbooleantrueEnable windowed metrics collection
metricsWindowsWindowOptions[]undefined (falls back to the built-in 1m/5m/1h/24h set)Overrides the windows behind status().metrics. Each entry is { size, stepMs }; window length = size × stepMs. Two windows may not cover the same horizon (their labels would collide); status()'s rate fields are computed over the shortest window
queueOrder'fifo' | 'lifo' | 'fifoWithPriority' | 'lifoWithPriority''fifoWithPriority'Queue dispatch order. fifo/lifo order purely by enqueue time; the *WithPriority variants make priority primary and break ties by enqueue time. See Choosing an ordering. Ignored if comparator is set
comparator(a, b) => numberCustom ordering over queued tasks (lower sorts/dispatches first); overrides queueOrder. Must be a consistent total order and must not throw (a NaN/non-number result degrades safely to an id tie-break; an exception does not)
debugbooleanfalseEnable debug logging and the permit-pool invariant check. Does not gate events — all events fire regardless

Every option is optional. The object below is the complete set of defaults — copy it and change only what you need:

import { Semaphore, type SemaphoreConfig } from 'regulo';

const config: SemaphoreConfig = {
  // Queue
  queueMaxLength: 1024,                    // max waiting tasks before QUEUE_FULL; min 1
  queueMaxTimeout: 10000,                  // ms a queued task waits before TIMEOUT; min 1
  queueMaxAge: 30000,                      // ms before the purge sweep ejects a task; min 1
  rejectOnFull: false,                     // true = no queuing; reject when all permits held
  // Circuit breaker
  circuitBreakerThreshold: 0.5,            // timeout rate in (0,1) that trips the circuit
  circuitBreakerWindow: 10000,             // ms sliding window for the rate; min 1000
  circuitBreakerWindowBucketWidth: 1000,   // ms per bucket; window / windowBucketWidth = bucket count
  circuitBreakerCooldown: 5000,            // ms open before a probe is allowed; min 1000
  circuitBreakerMinThroughput: 10,         // min requests in window before it can trip; min 1
  circuitBreakerMinFailures: 5,            // min failures in window before it can trip; min 1
  // circuitBreaker: undefined,            // no default — a CircuitBreakerStrategy instance overrides the options above
  // circuitBreakerFailurePredicate: undefined, // no default — use() rejections matching it count as breaker failures
  // Adaptive backoff
  backoffInitialTimeout: 50,               // ms initial delay on first timeout in a burst
  backoffMaxTimeout: 2000,                 // ms ceiling for the backoff delay
  backoffDecayFactor: 0.5,                 // decay per idle second, in (0,1)
  // Maintenance & observability
  purgeIntervalMs: 3000,                   // ms between stale-task purge sweeps; min 500
  metricsEnabled: true,                    // windowed metrics collection
  // metricsWindows: undefined,            // override the default 1m/5m/1h/24h windows
  debug: false,                            // debug logging + permit-pool invariant check
  // Ordering
  queueOrder: 'fifoWithPriority',          // 'fifo' | 'lifo' | 'fifoWithPriority' | 'lifoWithPriority'
  // comparator: undefined,                // no default — if set, overrides queueOrder
};

const semaphore = new Semaphore(10, config);

Events Reference

Listen with Semaphore.on(SemaphoreEvents.CIRCUITOPEN, handler). Payloads are typed per event (see SemaphoreEventMap); a handler's argument is inferred from the event constant.

Event constantString valuePayload
TASKACQUIRE'task-acquire'{ queued, running, probe? }
TASKRELEASE'task-release'{ queued, running, weight }weight is the permit count this release returned
TASKTIMEOUT'task-timeout'{ queueLength, backoffDelay, taskId }
TASKABORT'task-abort'none
QUEUEPURGE'queue-purge'QueuedTaskView{ id, priority, enqueueTime, weight }
QUEUEEVICT'queue-evict'QueuedTaskView — task evicted (rejected CIRCUIT_OPEN) because the circuit tripped while it was queued
CIRCUITOPEN'circuit-open'{ timeoutRate, recentTimeouts, total, reason? }
CIRCUITPROBING'circuit-probing'none
CIRCUITCLOSE'circuit-close'none
CIRCUITSTATECHANGE'circuit-state-change'{ from, to } — fires alongside every CIRCUITOPEN/CIRCUITPROBING/CIRCUITCLOSE, so one handler can sync breaker state to a dashboard instead of wiring up all three
SHUTDOWN'shutdown'reason: string

Error Codes

Every error Regulo raises — whether rejected from a promise or thrown synchronously — is a SemaphoreError instance with a code property you can switch on.

CodeWhen raised
CIRCUIT_OPENCircuit breaker is open
CIRCUIT_PROBINGCircuit is probing and a probe is already in flight
INVALID_ARGUMENTInvalid constructor/config value, or an invalid drain() timeout (thrown synchronously)
INVALID_WEIGHTweight is not an integer in 1..count
INVALID_PRIORITYpriority is not a finite number
QUEUE_FULLrejectOnFull is true, or queueMaxLength is exceeded
TIMEOUTTask waited longer than queueMaxTimeout; or drain() exceeded its deadline
ABORTEDCaller's AbortSignal fired
CANCELLEDTask was rejected by cancel()
SHUTDOWNshutdown() or reset() was called while the task was queued; or reset()/drain() was called on an already shut-down instance
PURGEDTask was ejected by the stale-task purge interval (queueMaxAge exceeded)
import { Semaphore, SemaphoreError } from 'regulo';

const semaphore = new Semaphore(10);
try {
  const release = await semaphore.acquire();
  // ...
  release();
} catch (error) {
  if (error instanceof SemaphoreError) {
    switch (error.code) {
      case 'CIRCUIT_OPEN':    // back off and retry later
      case 'TIMEOUT':         // shed load
      case 'ABORTED':         // client disconnected
    }
  }
}

Metrics

Generated with status().

{
  status: {
    running: number,           // permits currently held
    queued: number,            // tasks waiting in queue
    available: number,         // free permits
    inFlight: number,          // same as running (alias for clarity)
    pendingReleases: number,   // outstanding release closures; non-zero means permits are held
    circuitOpen: boolean,
    circuitProbing: boolean,
    backoffDelay: number,      // current backoff delay (ms) applied to scheduler wakeup
    requestsPerSecond: number, // over the shortest metrics window (1m by default)
    timeoutRate1m: number,     // timeout % over the shortest metrics window (1m by default)
    queueAge: number,          // ms since oldest queued task was enqueued
  },
  lifetime: {
    totalAcquired: number,
    totalReleased: number,
    totalTimeouts: number,
    totalPurged: number,       // tasks ejected by the stale-task purge (PURGED); not counted as timeouts
    totalEvictions: number,    // tasks evicted from the queue by a circuit trip
    circuitBreakerCooldownRemaining: number, // ms until circuit may probe
  },
  metrics: SemaphoreMetricsSnapshot | null  // null if metricsEnabled: false
}

Circuit Breakers

The breaker behind Semaphore is a pluggable strategy. Every breaker — the built-ins below and any you write — implements the CircuitBreakerStrategy contract (exported from the package root), and composes into a Semaphore via the circuitBreaker config option. Injecting an instance overrides the circuitBreaker* numeric options, the same precedence comparator has over queueOrder.

Renamed in 1.3.0: the former CircuitBreaker export is now SaturationCircuitBreaker, and its recordTimeout() method is now recordFailure(). Both are straight renames — behavior is unchanged. See the CHANGELOG for the migration.

Renamed in 1.4.0: the breaker's middle state is "probing," not "half-open" — while in this state the breaker admits exactly one canary request, never a fraction of normal capacity, so the old name was misleading. This touches the full public surface with no compatibility aliases: CircuitState's 'half-open' is now 'probing'; isHalfOpen is now isProbing; the CIRCUITHALFOPEN/'circuit-half-open' event is now CIRCUITPROBING/'circuit-probing'; the CIRCUIT_HALF_OPEN error code is now CIRCUIT_PROBING; status().status.circuitHalfOpen and the metrics snapshot's circuitHalfOpen are now circuitProbing; and the CIRCUITOPEN event's reason: 'half-open-probe-failed' is now reason: 'probe-failed'. Behavior is unchanged — see the CHANGELOG for the full migration table.

BreakerBehavior
SaturationCircuitBreakerThe default. A windowed failure-rate breaker: fed queue timeouts by the semaphore it trips on saturation (the wiring described above); fed your own signal (via reportFailure() or standalone) it trips on whatever you define as failure.
NoopCircuitBreakerNever trips — the semaphore as a pure limiter, with no load shedding.
ManualCircuitBreakerAn operator kill switch: open() sheds new acquires with CIRCUIT_OPEN until close(). No cooldown, no probe — recovery is a deliberate action. It gates new acquires only; call cancel() after open() if the queue should be shed too.
import { Semaphore, NoopCircuitBreaker, ManualCircuitBreaker } from 'regulo';

// Pure limiter — no breaker bookkeeping at all
const pool = new Semaphore(10, { circuitBreaker: new NoopCircuitBreaker() });

// Ops kill switch
const kill = new ManualCircuitBreaker();
const reports = new Semaphore(20, { circuitBreaker: kill });
// ... later, from an admin endpoint:
kill.open();  // shed load now
kill.close(); // resume

To write your own, implement CircuitBreakerStrategy and pass an instance as circuitBreaker. The contract notes live on the exported type's JSDoc — the essentials: checkAndTransition() returns true exactly once per open → probing transition, evaluateAndTrip() reports closed → open trips (the semaphore then emits CIRCUITOPEN and evicts the queue), the probe-slot methods may be no-ops if you never enter probing, and methods must not throw.

Feeding downstream errors: reportFailure()

The default breaker trips on saturation, not on your operation's errors (see How the circuit breaker works). If you also want error-driven tripping, report the failures you care about — the same window, threshold, cooldown, and probe recovery apply:

const semaphore = new Semaphore(10, { circuitBreakerThreshold: 0.3 });

await semaphore.use(async () => {
  try {
    return await callDownstream();
  } catch (error) {
    if (isServerError(error)) semaphore.reportFailure(); // count 5xx-style failures only
    throw error;
  }
});

A trip via reportFailure() behaves exactly like a saturation trip: queued tasks are evicted with CIRCUIT_OPEN and the CIRCUITOPEN event fires with reason: 'reported-failure'. Reported failures influence trip decisions only while the circuit is closed — probe outcomes remain acquisition-based.

For use()-based workloads, circuitBreakerFailurePredicate does this declaratively — and goes one step further:

const semaphore = new Semaphore(10, {
  circuitBreakerFailurePredicate: (error) => isServerError(error),
});

// Matching rejections from fn() count as breaker failures automatically —
// no manual reportFailure() call needed.
await semaphore.use(() => callDownstream());

With the predicate set, probes become fault-aware: a probe dispatched through use() whose operation fails with a matching error re-opens the circuit (the CIRCUITOPEN event fires with reason: 'probe-failed') instead of closing it on release. A probe whose rejection does not match still counts as a successful probe. The predicate must not throw; a thrown predicate is logged via console.warn and the rejection is treated as non-matching. The predicate only observes use() — callers using bare acquire()/tryAcquire() should call reportFailure() themselves.

Standalone usage

SaturationCircuitBreaker can be used independently — e.g. to wrap an HTTP client — where you decide what counts as a failure by calling recordFailure() on whatever signal you choose:

import { SaturationCircuitBreaker } from 'regulo';

const circuitBreaker = new SaturationCircuitBreaker({
  threshold: 0.5,
  window: 10000,
  cooldown: 5000,
  minThroughput: 10,
  minFailures: 5,
});

async function fetch(url: string) {
  // Check and transition open → probing if cooldown elapsed
  if (circuitBreaker.checkAndTransition()) {
    console.log('Circuit entering probing');
  }
  if (circuitBreaker.isOpen) throw new Error(`Circuit open, retry in ${circuitBreaker.cooldownRemaining}ms`);

  circuitBreaker.trackAttempt();
  try {
    const result = await httpClient.get(url);
    if (circuitBreaker.isProbing) circuitBreaker.handleProbeSuccess();
    return result;
  } catch (error) {
    circuitBreaker.recordFailure();
    if (circuitBreaker.isProbing) circuitBreaker.handleProbeFailure();
    else circuitBreaker.evaluateAndTrip();
    throw error;
  }
}

Feature Comparison

Regulo overlaps with several well-known libraries but sits at the intersection of bounded concurrency, prioritization, and resilience, with built-in observability.

Capabilityregulop-limitp-queueopossumcockatiel
Bounded concurrencyYesYesYesNoYes (bulkhead)
Priority queueYesNoYesNo
Weighted permitsYesNoNoNo
Circuit breakerYesNoNoYesYes
Adaptive backoffYesNoNoNoNo
Windowed metricsYesNoBasicYesNo
DependenciesZeroMinimalMinimalSeveralZero

Capabilities reflect each project's commonly documented feature set at the time of writing; check the respective projects for their current state. If you only need a concurrency cap, p-limit is smaller and simpler. If you need rich resilience policy composition (retry, timeout, fallback), cockatiel is a strong choice. Reach for regulo when you want prioritized, weighted concurrency limiting that you can monitor and that protects itself under sustained load.

Benchmarks

Full, reproducible benchmarks live in benchmarks/ — run them yourself with npm run benchmark:all. Figures below are from a real run on Node v22.16.0, darwin x64, mid-2018 Intel i9 Macbook Pro; your numbers will differ — re-run locally. Each library from Feature comparison is benchmarked only on the axis it actually shares with Regulo: the concurrency limiters on capping concurrency, the circuit breakers on per-call overhead.

🔥 Fast path, uncontended

Scenarioops/secvs. fastest
tryAcquire + release (with metrics)2.81M1.91x slower
tryAcquire + release5.37M1.0x fastest (baseline)
use() round-trip1.04M5.16x slower
use() round-trip (no metrics)1.34M4.01x slower

🎛️ Weighted acquire, uncontended

Scenarioops/secvs. fastest
use() weight=11.13M1.01x slower
use() weight=41.14M1.0x fastest (baseline)
use() weight=161.08M1.06x slower

Weighted permits add no meaningful overhead regardless of weight — claiming 16 burners at once costs about the same as claiming one.

⏳ Contended throughput (tasks/sec)

Scenariotasks/secvs. fastest
concurrency=4806.9k1.08x slower
concurrency=16823.7k1.05x slower
concurrency=64868.8k1.0x fastest (baseline)
concurrency=16, random priority727.5k1.19x slower

📈 status() snapshot cost

Queue depthops/secvs. fastest
0666.9k1.21x slower
100807.9kfastest
1000801.0k1.01x slower

status() is O(1) in queue depth — the cost is flat across queue depths (within run-to-run noise) because queue age is read from an enqueue-ordered index rather than by cloning and scanning the queue. status() is safe to call on a metrics scrape path for arbitrarily long task queues.

📊 regulo vs. other libraries — uncontended round-trip

Libraryops/secvs. fastest
cockatiel (bulkhead)4.10M1.0x fastest (baseline)
regulo1.32M3.10x slower
regulo (with metrics)1.08M3.80x slower
p-queue1.21M3.38x slower
p-limit1.17M3.50x slower

📊 regulo vs. other libraries — contended throughput @ concurrency=16 (tasks/sec)

Librarytasks/secvs. fastest
cockatiel (bulkhead)1.70M1.0x fastest (baseline)
p-queue1.10M1.54x slower
p-limit872.5k1.95x slower
regulo954.7k1.78x slower
regulo (with metrics)880.5k1.93x slower

🛡️ Circuit breaker overhead — closed/healthy circuit

Libraryops/secvs. fastest
cockatiel (circuitBreaker)2.75M1.94x slower
opossum1.62M3.29x slower
regulo ManualCircuitBreaker5.33M1.0x fastest (baseline)
regulo NoopCircuitBreaker4.92M1.08x slower
regulo SaturationCircuitBreaker3.82M1.40x slower

The picture is consistent. Cockatiel's bulkhead is the fastest limiter — and Regulo trades raw limiter throughput for an integrated priority heap, weighted permits, a pluggable circuit breaker, and (by default) windowed metrics in one component. Even with metrics enabled its uncontended round-trip now sits right alongside p-limit and p-queue; with metrics disabled it pulls ahead of both uncontended and matches them under contention. On the breaker axis the integration goes the other way: every breaker in Regulo's breakers module adds less per-call overhead than the fault breakers it is compared against. NoopCircuitBreaker and ManualCircuitBreaker show the floor (they do no accounting at all), and even SaturationCircuitBreaker — the default, and the only one keeping a real failure window — stays ahead because it defers failure accounting to an explicit failure signal instead of bookkeeping a rolling window on every call.

In practice none of this is the bottleneck. Regulo guards work that is far more expensive than the limiter itself: SSR renders, database queries, downstream API calls, measured in milliseconds. Even at ~880k tasks/sec under contention the per-task overhead is a few microseconds against operations thousands of times slower. If you only need a bare concurrency cap on cheap work in a hot loop, reach for a leaner limiter; see Feature comparison.

Test coverage

npx vitest run --coverage
 ✓ test/queue.test.ts (9 tests)
 ✓ test/ordering.test.ts (13 tests)
 ✓ test/validation.test.ts (5 tests)
 ✓ test/metrics.test.ts (22 tests)
 ✓ test/permit.test.ts (17 tests)
 ✓ test/backoff.test.ts (8 tests)
 ✓ test/heap.test.ts (10 tests)
 ✓ test/list.test.ts (9 tests)
 ✓ test/breaker.test.ts (23 tests)
 ✓ test/breakers-passthrough.test.ts (4 tests)
 ✓ test/keyed.test.ts (6 tests)
 ✓ test/semaphore-edges.test.ts (30 tests)
 ✓ test/semaphore.test.ts (120 tests)

 Test Files  13 passed (13)
      Tests  276 passed (276)
 % Coverage report from v8
----------------|---------|----------|---------|---------|
File            | % Stmts | % Branch | % Funcs | % Lines |
----------------|---------|----------|---------|---------|
All files       |   99.72 |    97.67 |     100 |     100 |
----------------|---------|----------|---------|---------|

Caveats

Before you crank the dial, know where the edges are:

  • One Semaphore is one failure domain. The circuit breaker and adaptive backoff are per-instance and shared across everything routed through it, so a saturation event on one dependency trips the breaker for all work in that instance. Don't multiplex unrelated downstreams or task types through a single Semaphore (and don't use weight/priority to fake it) — use (KeyedSemaphore instead, which enables a bounded set of per-esource keys), or one capacity pool plus a standalone SaturationCircuitBreaker per downstream key. See Choosing an ordering.
  • A free permit can sit idle behind a heavier head. The scheduler never dispatches past a head that doesn't fit, so under weighted permits one heavy task at the head can stall throughput even when there's capacity for the lighter tasks behind it. This is by design (it stops light work starving heavy work); how often it bites depends on your ordering — see Choosing an ordering.
  • drain() without a timeout can block indefinitely if a permit holder never releases. Always pass timeoutMs in graceful-shutdown paths.
  • The circuit breaker is a saturation breaker. It trips on queue-acquisition timeouts, not on errors thrown by your operation. See How the circuit breaker works.

License

MIT

Keywords

regulo

FAQs

Package last updated on 06 Jul 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts