Application structure
Resilience
Retry, timeout, circuit breaker, bulkhead, rate limiter and fallback policies with the Resilience.create chain; order, state and error codes.
Network requests and external resources fail, slow down and get overloaded. Resilience offers six policies for these situations and combines them in a single chain. It is independent of the rest of the framework, and can be used in services, in the data layer or inside a dataRequest.
import { Resilience } from "@motifx/core";
const policy = Resilience.create
.retry({ retries: 3, delay: 200 })
.timeout({ timeoutMs: 5000 })
.fallback({ fallback: () => [] });
const notes = await policy.execute(() => fetch("/api/notes").then((r) => r.json()));
import { Resilience } from "@motifx/core";
const policy = Resilience.create
.retry({ retries: 3, delay: 200 })
.timeout({ timeoutMs: 5000 })
.fallback({ fallback: () => [] });
const notes = await policy.execute(() => fetch("/api/notes").then((r) => r.json()));
The chain and its order
Resilience.create gives a new, empty chain; each policy method adds to the chain and returns the same object. execute(action, ctx?) wraps the added policies from the outside in, in the order they were added, and runs action:
const load = () => fetch("/api/notes").then((r) => r.json());
Resilience.create.retry().timeout({ timeoutMs: 5000 }).execute(load);
// retry( timeout( load ) ): each attempt has its own timeout
Resilience.create.timeout({ timeoutMs: 5000 }).retry().execute(load);
// timeout( retry( load ) ): all attempts share a single timeout
const load = () => fetch("/api/notes").then((r) => r.json());
Resilience.create.retry().timeout({ timeoutMs: 5000 }).execute(load);
// retry( timeout( load ) ): each attempt has its own timeout
Resilience.create.timeout({ timeoutMs: 5000 }).retry().execute(load);
// timeout( retry( load ) ): all attempts share a single timeout
timeout and fallback require an options object (the timeoutMs and fallback fields are mandatory); for the other policies the options are optional. action can be synchronous or return a Promise; execute always returns a Promise. ctx is a free-form object (ResilienceContext), passed as it is to action(ctx) and to the callbacks.
The types are exported: RetryOptions, TimeoutOptions, CircuitBreakerOptions, BulkheadOptions, RateLimiterOptions and FallbackOptions<T> for the options; CircuitState for the circuit state; ResiliencePolicy for the execute signature and ResilienceAction<T> for action. The Resilience class implements ResiliencePolicy; type a function that takes a chain as (policy: ResiliencePolicy) => ….
State lives in the chain. circuitBreaker, bulkhead and rateLimiter keep counters between calls, and every Resilience.create opens new counters. For these policies to be of any use, build the chain once (a service field, a module constant) and use the execute of the same object on every call. retry, timeout and fallback are stateless.
@Injectable({ lifetime: 'singleton' })
export class ApiClient {
private policy = Resilience.create
.circuitBreaker({ failureThreshold: 5, durationOfBreakMs: 30000 })
.retry({ retries: 2 })
.timeout({ timeoutMs: 8000 });
get<T>(url: string): Promise<T> {
return this.policy.execute(() => fetch(url).then((r) => r.json()));
}
}
@Injectable({ lifetime: 'singleton' })
export class ApiClient {
policy = Resilience.create
.circuitBreaker({ failureThreshold: 5, durationOfBreakMs: 30000 })
.retry({ retries: 2 })
.timeout({ timeoutMs: 8000 });
get(url) {
return this.policy.execute(() => fetch(url).then((r) => r.json()));
}
}
In this order each attempt runs with its own 8-second timeout. By default a timeout is neither retried nor counted as a failure for the circuit; if you want the circuit to open for a server that does not respond, give circuitBreaker shouldHandle: () => true.
Policies
retry(options?)
Waits and retries a failed action.
| Option | Default | Description |
|---|---|---|
retries |
3 |
The number of retries; total attempts are retries + 1. |
backoff |
'exponential' |
'exponential': delay × 2^(attempt−1); 'fixed': delay every time; 'none': no waiting. |
delay |
100 |
The base wait (ms). |
jitter |
0.1 |
The share of randomness added to the wait (0–1): the wait varies within ± jitter × wait. |
shouldHandle |
every error except a timeout | (error) => boolean; if it returns false, there is no retry and the error is thrown right away. The default does not retry a TimeoutError (the timeout policy's error) or a rejection with undefined/null. |
onRetry |
— | (attempt, error, waitMs, ctx); called before each wait, and awaited if it returns a Promise. |
If the last attempt also fails, the last error is thrown. To retry a request that timed out as well, give shouldHandle: () => true.
`timeout(
If action does not finish within timeoutMs, a MotifError with the code MJX605 and the name 'TimeoutError' is thrown. The running operation is not cancelled (wire up an AbortController yourself for fetch); its result is just not waited for. The timer is cleared in both cases.
circuitBreaker(options?)
After failures in a row, it rejects calls for a while without making them at all, giving the resource room to recover.
| Option | Default | Description |
|---|---|---|
failureThreshold |
5 |
In the CLOSED state, this many consecutive failures open the circuit. A success in between resets the counter. |
successThreshold |
2 |
In the HALF_OPEN state, this many consecutive successes close the circuit. |
durationOfBreakMs |
60000 |
How long the OPEN state lasts; when it is over, the first call moves to HALF_OPEN. |
shouldHandle |
every error except a timeout | Which errors count as failures. The default does not count a TimeoutError or a rejection with undefined/null. |
onStateChange |
— | (old, new); 'CLOSED' \| 'OPEN' \| 'HALF_OPEN'. |
In the OPEN state, execute throws MJX602 without calling action. In HALF_OPEN, a single failure opens the circuit again. Follow the circuit state with onStateChange; the chain has no "read the state" method.
bulkhead(options?)
Limits the number of actions running at the same time; the rest go into a queue.
| Option | Default | Description |
|---|---|---|
maxConcurrent |
10 |
The concurrency limit. |
maxQueue |
50 |
The limit of the waiting queue; when it is full, MJX603 is thrown. |
The queue is FIFO; when a call finishes, the next one starts.
rateLimiter(options?)
A token bucket: each call spends a token, and tokens refill at intervals.
| Option | Default | Description |
|---|---|---|
tokensPerInterval |
1 |
Tokens added each interval. |
intervalMs |
1000 |
The interval. |
capacity |
tokensPerInterval |
The bucket's capacity; the bucket starts full. Give a larger value if you want room for bursts. |
If there are no tokens, execute does not wait; it throws MJX604. The defaults mean "at most 1 per second"; for "at most N per second", tokensPerInterval: N is enough. capacity: 10, tokensPerInterval: 1 lets the first 10 calls through at once, then allows 1 per second.
fallback({ fallback, shouldHandle? })
If action fails, the value returned by fallback(error, ctx) (synchronous or a Promise) becomes the result. If shouldHandle (every error by default) returns false, the error is thrown. Put first in the chain, it catches the last error of every policy inside it.
decorate(policy, fn)
Ties a chain to a function and makes it reusable:
import { Resilience, decorate } from "@motifx/core";
const loadNotes = decorate(
Resilience.create.retry({ retries: 2 }).timeout({ timeoutMs: 5000 }),
() => fetch("/api/notes").then((r) => r.json()),
);
const notes = await loadNotes(); // same as policy.execute(fn, ctx)
import { Resilience, decorate } from "@motifx/core";
const loadNotes = decorate(
Resilience.create.retry({ retries: 2 }).timeout({ timeoutMs: 5000 }),
() => fetch("/api/notes").then((r) => r.json()),
);
const notes = await loadNotes(); // same as policy.execute(fn, ctx)
decorate(policy: ResiliencePolicy, fn: ResilienceAction<T>) accepts as its first argument any object that has an execute(action, ctx?) method; the Resilience chain implements this interface.
Error codes
| Code | Policy | Meaning |
|---|---|---|
MJX602 |
circuitBreaker |
The circuit is open; action was not called. |
MJX603 |
bulkhead |
The queue is full. |
MJX604 |
rateLimiter |
No tokens. |
MJX605 |
timeout |
Timeout (name: 'TimeoutError'). |
All four are MotifErrors and are thrown from execute; they can be caught with fallback. With the default shouldHandle, retry and circuitBreaker do not count a timeout, but they do count the other three.
Next step
To learn about routing between pages, go on to Introduction to Routing.