Skip to content

@studnicky/request-executor

One-shot request execution pattern composing @studnicky/fetch, @studnicky/retry, @studnicky/signal, @studnicky/timing, and @studnicky/context.

Install

bash
pnpm add @studnicky/request-executor

Usage

RequestExecutor does not perform HTTP calls itself — the caller's fn receives the composed FetchClient 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; an optional Timing instance brackets the whole loop with a single span; an optional Context runs the whole call inside a fresh scope:

ts
import type { RequestContextType, ResponseContextType } from '@studnicky/fetch/interfaces';
import type { RetryConfigInterface, RetryContextType } from '@studnicky/retry';

import { FetchClient } from '@studnicky/fetch';
import { Retry } from '@studnicky/retry';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';

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

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

  static override create(config = {}): TelemetryFetchClient {
    return new this(config);
  }

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

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

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

  constructor(config?: Partial<RetryConfigInterface>) {
    super(config ?? {});
  }

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

/**
 * Advanced extension: RequestExecutor has no hooks of its own — observability is
 * delegated entirely to the composed primitives. A subclass can still add
 * convenience behavior by reaching the composed instances through the getters.
 */
class ReportingRequestExecutor extends RequestExecutor {
  // `this.create(...)` (not `RequestExecutor.create(...)`) so the inherited factory's
  // `new this(...)` binds to ReportingRequestExecutor — same `new this()` polymorphism
  // FetchClient/Timing/Retry use for their own subclass factories.
  static tracked(fetchClient: TelemetryFetchClient, retry: TelemetryRetry): ReportingRequestExecutor {
    const result = this.create({ 'fetchClient': fetchClient, 'retry': retry }) as ReportingRequestExecutor;
    return result;
  }

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

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

Transparency contract

RequestExecutor 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
fetchClientFetchClient instance or ClientConfigTypeFetchClient.create({})
retryRetry instance or Partial<RetryConfigInterface>Retry.create({})
signalSignal instanceSignal.create()
timingTiming instanceundefined — no span recorded
contextContext instanceundefined — no scope wrapping
deadlineMsDefault deadline (ms) for calls that don't pass their ownundefined
GetterReturns
getFetchClient()The composed FetchClient instance
getRetry()The composed Retry instance
getSignal()The composed Signal instance
getTiming()The composed Timing instance, or undefined
getContext()The composed Context instance, or undefined

Every getter returns the exact instance passed to create()/builder() — never a copy or wrapper. A caller who subclassed FetchClient for auth headers, Retry for custom backoff or classification, or Timing/Context for correlation keeps full access to those subclasses' own hooks; RequestExecutor never re-exposes a stage a wrapped primitive's hook already covers (no redundant "before request" hook, no redundant "before attempt" hook).

Composition order

context scope wraps the whole call → timing span brackets the retry loop → retry loop wraps the caller's fn → the composed cancellation AbortSignal threads into whatever call fn makes.

When to stop using this and move to Dagonizer

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 and belongs in Dagonizer, not in a loop of RequestExecutor calls glued together by hand.

Documentation

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

Source on GitHub