Skip to content

@studnicky/boundary-kit

Composes @studnicky/throttle, @studnicky/resilience's CircuitBreaker, and @studnicky/retry into a fixed-order boundary call pattern.

Install

bash
pnpm add @studnicky/boundary-kit

Usage

BoundaryKit composes calls in the fixed order throttle → circuitBreaker → retry → fn. Import BoundaryKit from @studnicky/boundary-kit, call BoundaryKit.create({ circuitBreaker, retry, throttle }), then pass the operation to boundary.execute(fn). Omitted configuration fields resolve to package defaults.

ts
import type { CircuitBreakerOptionsInterface } from '@studnicky/resilience';
/** observedBoundaryKit — default construction, then direct composition of subclassed primitives. Run: npx tsx examples/observedBoundaryKit.ts */
import type { RetryConfigInterface, RetryContextInterface } from '@studnicky/retry/interfaces';
import type { ThrottleConfigEntity } from '@studnicky/throttle/entities';

import { RuntimeError } from '@studnicky/errors';
import { CircuitBreaker } from '@studnicky/resilience';
import { Retry } from '@studnicky/retry';
import { Throttle } from '@studnicky/throttle';
import assert from 'node:assert/strict';

import { BoundaryKit } from '../src/index.js';

/**
 * Advanced usage: BoundaryKit has no hooks of its own — observability is delegated
 * entirely to the composed primitives. Subclass Throttle/CircuitBreaker/Retry directly
 * and pass the pre-built instances in; their own hooks keep firing exactly as they
 * would standalone. Retain those original references for direct observation.
 */
class TelemetryThrottle extends Throttle {
  readonly acquisitions: number[] = [];

  constructor(config?: Partial<ThrottleConfigEntity.Type>) {
    super(config);
  }

  protected override onAcquire(activeCount: number, queuedCount: number): void {
    console.log(`[throttle] slot acquired (active=${String(activeCount)}, queued=${String(queuedCount)})`);
    this.acquisitions.push(activeCount);
  }
}

class TelemetryCircuitBreaker extends CircuitBreaker {
  readonly rejections: number[] = [];

  constructor(options: CircuitBreakerOptionsInterface) {
    super(options);
  }

  protected override onReject(): void {
    console.log('[circuitBreaker] rejected — circuit is open');
    this.rejections.push(Date.now());
  }
}

class TelemetryRetry extends Retry {
  readonly scheduledRetries: number[] = [];

  constructor(config?: RetryConfigInterface) {
    super(config ?? {});
  }

  protected override onRetryScheduled(context: RetryContextInterface): void {
    console.log(`[retry] attempt ${String(context.attemptNumber)} scheduled retry`);
    this.scheduledRetries.push(context.attemptNumber);
  }
}

class ObservedBoundaryKitExample {
  static async run(): Promise<void> {
    /**
     * Default construction: BoundaryKit.create() with no config resolves every composed
     * primitive to sensible defaults — including CircuitBreaker, which has no zero-arg
     * default of its own.
     */
    const defaultKit = BoundaryKit.create();

    let defaultAttempts = 0;

    const defaultResult = await defaultKit.execute(() => {
      defaultAttempts += 1;

      if (defaultAttempts < 2) {
        throw RuntimeError.create('transient failure');
      }

      const result = Promise.resolve('default-ok');
      return result;
    });

    console.log('Default kit result:', defaultResult, `(${String(defaultAttempts)} attempts)`);

    const throttle = new TelemetryThrottle({ 'concurrencyLimit': 3 });
    const circuitBreaker = new TelemetryCircuitBreaker({ 'failureThreshold': 2, 'resetTimeoutMs': 5000 });
    const retry = new TelemetryRetry({ 'maximumRetries': 2 });

    const observedKit = BoundaryKit.create({
      'circuitBreaker': circuitBreaker,
      'retry': retry,
      'throttle': throttle
    });

    let flakyAttempts = 0;

    const observedResult = await observedKit.execute(() => {
      flakyAttempts += 1;

      if (flakyAttempts < 2) {
        throw RuntimeError.create('transient failure');
      }

      const result = Promise.resolve('observed-ok');
      return result;
    });

    console.log('Observed kit result:', observedResult);
    console.log('Throttle acquisitions:', throttle.acquisitions);
    console.log('Retry scheduled attempts:', retry.scheduledRetries);

Try it

Loading example…

The output shows the composed throttle → circuitBreaker → retry → fn order in practice, observed via each primitive's own hooks.

Transparency contract

BoundaryKit introduces no hook of its own — every observable stage is already covered by the primitive it delegates to. Each composed primitive accepts either a pre-built instance (subclassed or not) or the config shape passed straight to that primitive's own create():

Config keyAcceptsDefault
throttleThrottle instance or ThrottleConfigEntity.TypeThrottle.create()
circuitBreakerCircuitBreaker instance or CircuitBreakerOptionsInterface{ failureThreshold: 5, resetTimeoutMs: 30_000 }
retryRetry instance or RetryConfigInterfaceRetry.create()

A caller can pass pre-built subclass instances and retain those references for direct access to their hooks and state. BoundaryKit adds no redundant "before call" or "on failure" hook because the composed primitives already own those lifecycle stages.

Composition order

Throttle bounds concurrency first, so the circuit breaker and retry never observe more concurrent load than the throttle admits. The circuit breaker wraps retry, so a tripped circuit fails fast BEFORE any retry attempt runs — reversing this order would let every retry attempt re-enter and re-trip the breaker individually, wasting attempts against a dependency already known to be broken. Retry is innermost, operating directly against the real call. This is the kit's entire value-add: the order is non-obvious and easy to get backwards by hand, and getting it wrong changes behavior silently.

Aborted throttle calls

BoundaryKit#execute() tracks whether the inner operation completes separately from its resolved value. An operation that legitimately resolves undefined or returns void completes normally with that value. BoundaryKitAbortedError is reserved for a throttle discard caused by detach-and-abandon abort behavior, where the inner operation never runs.

When this composition tips into orchestration

BoundaryKit protects exactly one call (with its own internal throttle/circuit/retry state). It has no concept of a node, a graph, or a dependency between multiple calls. Once a workflow needs to coordinate the outcome of one BoundaryKit#execute() call to decide whether or how to run a second one — branching, fan-out across dependent calls, checkpoint/resume, or cross-call retry budgets — that is workflow orchestration, not a loop of BoundaryKit calls glued together by hand.

Documentation

Full reference: https://studnicky.github.io/substrate/packages/boundary-kit

Interfaces

@studnicky/boundary-kit/interfaces exports boundary configuration and resolved-dependency contracts.

typescript
import type { BoundaryKitConfigInterface } from '@studnicky/boundary-kit/interfaces';

Exports

SymbolPurposeImport path
BoundaryKitApplies throttle, circuit breaking, and retry in a fixed order.@studnicky/boundary-kit
BoundaryKitAbortedErrorSignals a detached throttle call that never ran.@studnicky/boundary-kit

Source on GitHub