@gomani/resilience
v0.14.0
Published
Fault-isolation primitives: circuit breakers, bulkheads, retries, timeouts, and fallbacks — so every external call fails fast and degrades instead of cascading.
Maintainers
Readme
@gomani/resilience
Fault isolation + graceful degradation. Within one process, the thing that actually kills a monolith is a slow or dead dependency exhausting shared resources and cascading. These primitives contain that, so every external call fails fast and degrades instead of taking the app down:
circuitBreaker— after a dependency fails repeatedly, stop calling it and fail fast; probe to recover.bulkhead— cap concurrent calls to one dependency (and bound the wait queue) so it can only tie up its own slice of resources.pool— a bulkhead for connections: borrow/return, bounded atmax.retry— retry with exponential backoff + jitter;retryIfto skip non-retryable errors.withTimeout— a hard per-call deadline.withFallback— degrade to a cached/default result (the generalizedwithProviderFallback()).guard— composes all of the above into one wrapper.
The primitives are runtime-dependency-free, isomorphic (server + client), and fully deterministic under test
(clocks and randomness are injectable) — usable standalone on any project. The graceful-degradation layer
adds loadOrDegrade (a loader-level fallback) and <Degradable> (declare a feature's degraded UI); the
<Degradable> UI uses @gomani/core (tree-shaken away when you import only the primitives).
guard — the front door
import { guard } from '@gomani/resilience';
// Wrap any external call once; reuse the wrapper so the breaker's history is shared.
const charge = guard(paymentProvider.initiate, {
name: 'payments',
timeoutMs: 4000, // per-attempt deadline
retries: 2, // retry twice with backoff
breaker: { failureThreshold: 5, resetMs: 30_000 }, // trip after 5 fails, probe after 30s
bulkhead: { maxConcurrent: 10, maxQueue: 20 }, // cap concurrency, shed beyond
fallback: (err, req) => queueForLater(req), // degrade instead of failing
});
await charge(request); // fails fast when the provider is down; the fallback keeps the app serving
charge.circuit?.state; // 'closed' | 'open' | 'half-open' — for a metrics viewLayering, outer → inner: fallback → bulkhead → circuit breaker → retry → timeout → fn. So concurrency is
capped first, the breaker fails fast when the dependency is down, retries happen beneath a single breaker
verdict, each attempt has a deadline, and the fallback degrades whatever still fails.
Where it plugs into Gomani
Any external call is a candidate: a payment provider, the database, R2/object storage, a third-party API. A
route loader can wrap its dependency and return { data, degraded: true } on fallback; a
background worker guards its own dependencies. The primitives are the shared substrate the rest
of the resilience story builds on.
Graceful degradation
Partial failure should mean partial functionality, not a white screen. Three pieces:
The island error boundary (in @gomani/runtime, on by default). If an island throws while rendering or
mounting, it degrades to the HTML the server already rendered instead of taking the page down — and because
every island activates independently, siblings and the static shell are untouched. Zero config; a failed
island is marked data-gomani-failed.
loadOrDegrade — a route loader serves a fallback (tagged degraded) when its dependency is down, so
the page renders a useful, degraded shell with no client waterfall:
export const loader = () =>
loadOrDegrade(
() => fetchLive(),
() => readCache(),
);
// in the page: data.degraded ? <Cached data={data.data} /> : <Live data={data.data} /><Degradable> — declare a feature's degraded UI. Renders children when healthy; when when is true it
degrades per its mode:
<Degradable when={isOffline} mode="queue" fallback={<QueuedNotice />}>
<WriteForm />
</Degradable>cached— the live source is down; show last-known data (from@gomani/data's store).readOnly— writes are unavailable; show a read-only variant.hidden— the feature is unavailable; remove it rather than show a broken control.queue— writes are accepted but deferred to@gomani/data's offline queue and synced on reconnect.
when may be a boolean (server-decided during SSR) or a signal (reactive on the client — bind it to
@gomani/data's online signal or a guard's circuit state). The degraded region is announced (role="status")
for assistive tech.
Individual primitives
const cb = circuitBreaker(call, { failureThreshold: 5, resetMs: 30_000 });
const bh = bulkhead({ maxConcurrent: 10, maxQueue: 20 });
await bh.run(() => call());
await retry(() => call(), { retries: 3, backoff: 'exp', baseMs: 100 });
await withTimeout(() => call(), 5000);
await withFallback(
() => live(),
() => cached(),
);
const p = pool(() => createConnection(), { max: 8, acquireTimeoutMs: 2000 });
await p.use((conn) => conn.query('…'));Typed failures — CircuitOpenError, BulkheadFullError, TimeoutError — let callers react precisely.
