Skip to content

@studnicky/request-executor

One-shot request execution pattern composing @studnicky/fetch, @studnicky/retry, @studnicky/signal, and an optional scope port.

Install

bash
pnpm add @studnicky/request-executor

Usage

RequestExecutor does not perform HTTP calls itself — the caller's fn receives the supplied FetchClientInterface and a composed AbortSignal (merged from a caller-supplied AbortSignal and/or deadlineMs via Signal#compose()) and decides which verb to call. The call runs through the composed Retry loop, bracketed by the onExecuteStart/onExecuteComplete/onExecuteError lifecycle hooks; an optional scope factory runs the whole call inside a fresh scope:

ts
import type { RetryConfigInterface, RetryContextInterface } from '@studnicky/retry/interfaces';
/** observedRequestExecutor — direct composition of caller-owned subclassed primitives. Run: npx tsx examples/observedRequestExecutor.ts */

import { RuntimeError } from '@studnicky/errors';
import {
  type RequestContextInterface,
  type ResponseContextInterface
} from '@studnicky/fetch';
import { FetchClient } from '@studnicky/fetch/node';
import { Retry } from '@studnicky/retry';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';

import type { RequestExecutorDepsInterface } from '../src/interfaces/index.js';

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

class TelemetryFetchClient extends FetchClient {
  readonly requestPaths: string[] = [];

  protected override onRequest(context: RequestContextInterface): Promise<RequestContextInterface> {
    console.log(`[fetch] ${context.metadata.method} ${context.metadata.path}`);
    this.requestPaths.push(context.metadata.path);
    const result = Promise.resolve(context);
    return result;
  }

  protected override onResponse(context: ResponseContextInterface): Promise<ResponseContextInterface> {
    console.log(`[fetch] <- ${context.response.status}`);
    const result = Promise.resolve(context);
    return result;
  }
}

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

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

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

/**
 * RequestExecutor's own `onExecuteStart`/`onExecuteComplete`/`onExecuteError` hooks give
 * span-level observability around the whole retry loop; retry-level reporting (attempt counts)
 * still lives on `Retry` itself, so the subclass explicitly owns the `TelemetryRetry` dependency
 * it needs for `report()`.
 */
class ReportingRequestExecutor extends RequestExecutor {
  readonly #retry: TelemetryRetry;
  readonly errorMessages: string[] = [];

  protected constructor(deps: RequestExecutorDepsInterface) {
    super(deps);
    if (!(deps.retry instanceof TelemetryRetry)) {
      throw RuntimeError.create('ReportingRequestExecutor requires TelemetryRetry');
    }
    this.#retry = deps.retry;
  }

  // `this.create(...)` (not `RequestExecutor.create(...)`) so the inherited factory's
  // `new this(...)` binds to ReportingRequestExecutor — same `new this()` polymorphism
  // FetchClient/Retry use for their own subclass factories.
  static tracked(fetchClient: TelemetryFetchClient, retry: TelemetryRetry): ReportingRequestExecutor {
    const result = this.create({ 'fetchClient': fetchClient, 'retry': retry });

    if (!(result instanceof ReportingRequestExecutor)) {
      throw RuntimeError.create('RequestExecutor subclass factory returned the wrong instance type');
    }

    return result;
  }

  protected override onExecuteError(error: Error): void {
    console.log('[execute] failed', error.message);
    this.errorMessages.push(error.message);
  }

  report(): { 'retries': number; 'totalRequests': number } {
    const stats = this.#retry.getStats();

    return { 'retries': stats.totalRetries, 'totalRequests': stats.totalRequests };
  }
}

Try it

Loading example…

The output shows the native browser client retrying two temporary failures and resolving the final response through the same executor contract used by server consumers.

Lifecycle hooks

RequestExecutor exposes three protected lifecycle hooks, no-ops by default: onExecuteStart() fires before the retry loop begins, onExecuteComplete<T>(result) fires after it resolves, and onExecuteError(error) fires once retries are exhausted. All three run through an internal HookInvoker that records a throwing override without replacing execute()'s resolved result or thrown error. The fetch client is an explicit runtime adapter; retry and signal retain their portable defaults:

Config keyAcceptsDefault
fetchClientFetchClientInterface, including BrowserFetchClient or the Node adapterRequired
retryRetry instance or RetryConfigInterface from @studnicky/retryRetry.create({})
signalSignal instanceSignal.create()
scopeRequestScopeFactoryInterfaceundefined — no scope wrapping
deadlineMsDefault deadline (ms) for calls that don't pass their ownundefined

Callers retain references to supplied fetch client, retry, signal, and scope implementations when they need those primitives' own hooks or state. The executor never re-exposes a stage a wrapped primitive already owns.

Import RequestExecutor from @studnicky/request-executor, its schema namespace from @studnicky/request-executor/entities, and its type contracts from @studnicky/request-executor/interfaces. Import a runtime fetch adapter from @studnicky/fetch/browser or @studnicky/fetch/node.

Composition order

The optional request scope wraps the whole call → onExecuteStart/onExecuteComplete/onExecuteError bracket the retry loop → retry loop wraps the caller's fn → the composed cancellation AbortSignal threads into whatever call fn makes.

When this composition tips into orchestration

RequestExecutor executes exactly one call (with its own internal retry attempts). 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 RequestExecutor#execute() call to decide whether or how to run a second one — branching, fan-out across dependent requests, checkpoint/resume, or cross-call retry budgets — that is workflow orchestration, not a loop of RequestExecutor calls glued together by hand.

Documentation

Full reference: https://studnicky.github.io/substrate/packages/request-executor

Entities

@studnicky/request-executor/entities exports request deadline schemas.

typescript
import { RequestDeadlineEntity } from '@studnicky/request-executor/entities';

Interfaces

@studnicky/request-executor/interfaces exports executor configuration, dependency, and execution-option contracts.

typescript
import type { RequestExecutorConfigInterface } from '@studnicky/request-executor/interfaces';

Exports

SymbolPurposeImport path
RequestExecutorComposes request dependencies for a retried one-shot call.@studnicky/request-executor

Source on GitHub