inkan

Circuit breaker

Stop calling what keeps failing: around an outgoing call, or per route.

Needs inkan 0.8

@inkanjs/circuit-breaker needs inkan 0.8.0 or newer, below 1.0.

npm i @inkanjs/circuit-breaker

A service that is down gets time to come back instead of a queue of retries, and your callers get an answer at once instead of waiting for the next timeout. No dependencies.

closed     calls go through; `threshold` failures within `window` ms open it
open       calls fail at once for `cooldown` ms
half-open  one call goes through as a probe: success closes it, failure opens it again

Outgoing calls: circuit(fn)

The common case: a service you call that sometimes falls over. Wrap the call once, and use the wrapped function as before.

import {  } from "@vxnsin/inkan";
import { ,  } from "@inkanjs/circuit-breaker";

const  = ((: string) => (`https://rates.example/${}`, { : .(2000) }), {
  : "rates",
  : 5,
  : 30_000,
});

.("/prices/:currency", async () => {
  try {
    const  = await (..);
    if (!.ok) throw (502, "rates-failed");
    return await .json();
  } catch () {
    if ( instanceof ) throw new (503, "rates-unavailable", , {}, { "retry-after": (.retryAfter) });
    throw ;
  }
});
  • What the function throws counts as a failure, and so does a returned value whose status is 500 or more (a fetch Response); that response is still handed back.
  • While open, the wrapped function rejects at once with CircuitOpenError (code: "circuit-open", retryAfter in seconds, key the name), without calling.
  • rates.state is "closed", "open" or "half-open"; rates.reset() closes it.
  • Give every call a timeout of its own (AbortSignal.timeout): a call that hangs is not a failure until it ends.

Incoming routes: breaker()

import {  } from "@inkanjs/circuit-breaker";

.(({ : 5, : 10_000, : 30_000 }));
GET /reports  → 500, 500, 500, 500, 500
GET /reports  → 503 circuit-open, retry-after: 30   (the handler does not run)
… 30 s later
GET /reports  → the probe: 200 closes it, a 5xx opens it for another 30 s

A shared plugin: a circuit per route of the scope it is registered in. While a route's circuit is open, its requests get a 503 problem circuit-open with retry-after, in an onRequest hook, before the body is read or the handler runs. It protects what is behind a route (a database, an upstream) from being hammered while it is down. A failure is an answer of 500 or more; a 4xx problem is an answer, not a failure.

Behaviour

  • The state lives in this process. With workers each process counts its own failures, which is mostly what you want: each finds out on its own that a service is down.
  • In half-open exactly one probe goes through; the others fail at once with retryAfter 1. A probe that never settles is given up after another cooldown.
  • key runs for every request of the scope: keep it cheap. Requests no route takes are left alone.
  • Circuit is exported for a breaker of your own: enter() before a call (it throws CircuitOpenError while open), settle(token, failed) after it.

Options

optiondefault
threshold5failures within window that open the circuit
window10000ms the failures are counted over
cooldown30000ms it stays open before one probe goes through
isFailurethrown, or status ≥ 500(status, err) => boolean
nowDate.nowthe clock in ms, your own in tests
onStateChangenone(to, from, key), for logs and metrics
namenonecircuit() only: names its errors and changes
keythe route, "GET /teas/:id"breaker() only: (ctx) => string, which circuit a request belongs to, such as one per tenant
max10000breaker() only: circuits kept, the least recently used dropped first

OpenAPI

Unchanged.

On this page