wats.sh
Guides

Transport and Testing

The @wats/graph Transport seam — testing with MockTransport, opt-in reliable transport, and the interceptor primer.

shape-only — MockTransport and consumer fixtures; no live Meta calls · reviewed 2026-06-12

@wats/graph ships a small Transport seam under GraphClient so that every HTTP concern — retries, authentication refresh, tracing, mocking in tests — lives in a composable layer you control. The default transport (createFetchTransport) is a thin wrapper over globalThis.fetch. Tests inject createMockTransport via the @wats/graph/testing subpath and assert on a requests array rather than monkey-patching global state.

The Transport contract

import type { Transport, TransportRequest, TransportResponse } from "@wats/graph";

A Transport has one method:

interface Transport {
  request(req: TransportRequest, opts?: { signal?: AbortSignal }): Promise<TransportResponse>;
}

TransportRequest is a fully-resolved absolute URL, a Headers instance, the chosen BodyInit | null, and the HTTP method (GET | POST | PUT | PATCH | DELETE). TransportResponse exposes status, headers, body: ReadableStream<Uint8Array> | null, and arrayBuffer() / text() / json<T>() methods.

GraphClient never looks at the underlying fetch implementation; everything goes through the Transport.

Recipe 1 — Testing with createMockTransport

Import the mock factory from the @wats/graph/testing subpath. This subpath is separate so production bundles do not pull the mock in by accident.

import { GraphClient } from "@wats/graph";
import { createMockTransport } from "@wats/graph/testing";

const handle = createMockTransport({
  responses: [
    {
      status: 200,
      headers: { "content-type": "application/json" },
      body: { messaging_product: "whatsapp", messages: [{ id: "wamid.HBgM" }] }
    }
  ]
});

const client = new GraphClient({
  accessToken: "test-token",
  apiVersion: "v25.0",
  baseUrl: "https://graph.facebook.com",
  transport: handle.transport
});

const res = await client.messages.sendMessage({
  phoneNumberId: "123",
  to: "15551230000",
  text: "hi"
});

// Assert on the recorded request — method/url/headers/body — verbatim.
expect(handle.requests.length).toBe(1);
expect(handle.requests[0]?.url).toBe(
  "https://graph.facebook.com/v25.0/123/messages"
);
expect(handle.requests[0]?.headers.get("authorization")).toBe("Bearer test-token");

createMockTransport options:

FieldPurpose
responsesFIFO queue of response specs; each request consumes one.
defaultResponseUsed after the queue is exhausted.
onRequest(req)Spy called before dispatch.
failError (or (req) => Error) to throw instead of responding. Simulates fetch-level failures.
failAfterThreshold at which fail starts firing (the first N requests still respond normally).

Response specs can be objects { status, headers?, body? } or functions (req) => spec for per-request dynamic responses. The body can be a string, Uint8Array, a plain object (auto-JSON with application/json content-type), or null.

The handle exposes respond(spec) to push into the queue mid-test and reset() to clear both the queue and the recorded requests.

Recipe 2 — Opt-in reliable transport

Any Transport is just an object with a request method. WATS ships createReliableTransport as an opt-in decorator for retries with jittered backoff, Retry-After handling, and per-attempt timeouts without adding a dependency or changing the default GraphClient behavior.

import {
  GraphClient,
  createFetchTransport,
  createReliableTransport
} from "@wats/graph";

const transport = createReliableTransport(createFetchTransport(), {
  retries: 3,
  baseDelayMs: 200,
  maxDelayMs: 30_000,
  timeoutMs: 10_000,
  onRetry: ({ attempt, delayMs, response }) => {
    console.log(`retry ${attempt} after ${response?.status} in ${delayMs}ms`);
  }
});

const client = new GraphClient({
  accessToken: token,
  apiVersion: "v25.0",
  transport
});

The decorator retries transient GET/DELETE failures (GraphNetworkError, HTTP 429, and HTTP 5xx) using exponential backoff with full jitter. It honors Meta's Retry-After header for 429 / 503 responses and caps every delay at maxDelayMs. Caller aborts are never retried and never reach the inner transport: a caller AbortSignal is forwarded into the rate limiter's wait, so an abort during admission rejects promptly without consuming a token or dispatching a request. Per-attempt timeouts compose with a caller-provided AbortSignal via native AbortSignal.any / AbortSignal.timeout, and each attempt gets the full timeoutMs budget (it is per-attempt, not a total deadline).

WATS does not retry non-idempotent POST / PUT / PATCH request failures by default, with one narrow exception: HTTP 429 is treated as a rate-limit response across methods. A no-response POST failure (connection reset, socket closed mid-upload, read timeout) is ambiguous: the server may have received and applied the request before the response was lost. An Idempotency-Key header on its own proves nothing — the client sets it, and the server has no obligation to honor it unless the endpoint documents verified remote idempotency. The retryPosts modes ("network-only", "always") opt into retry only when you have independently verified the endpoint is idempotent or dedupes by Idempotency-Key server-side. The key is a necessary, not sufficient, condition; it never overrides the default "never". The default client applies no retries.

The same shape composes: auth-refresh -> reliability -> tracing -> fetch. Each layer is a function that takes (inner: Transport) => Transport. Because every layer sees the same TransportRequest/TransportResponse shape, the layers stay independently testable.

Recipe 2a — Streaming request bodies (ReadableStream)

GraphClient.request treats ReadableStream request bodies as an opaque passthrough: the stream is handed to the underlying transport by reference (never buffered, never JSON.stringify'd). If the caller does not supply a content-type, the client defaults to application/octet-stream rather than application/json.

const stream = new ReadableStream<Uint8Array>({
  start(controller) {
    controller.enqueue(new Uint8Array([/* binary frame 1 */]));
    controller.enqueue(new Uint8Array([/* binary frame 2 */]));
    controller.close();
  }
});

await client.request<{ id: string }>({
  method: "POST",
  path: "/media",
  body: stream,
  // Optional. Omit to accept the application/octet-stream default.
  headers: { "content-type": "application/octet-stream" }
});

This path composes with the other body types: FormData, Blob, ArrayBuffer, ArrayBufferView (Uint8Array/DataView), URLSearchParams, and string all pass through unchanged; only plain objects are JSON-serialized. This is how Media uploads and streamed attachments ride through the same primitive.

Recipe 3 — Interceptor primer

createFetchTransport accepts an optional interceptors array for the common case where you just want to rewrite the request or observe the response without wrapping the entire Transport:

import { createFetchTransport, type TransportInterceptor } from "@wats/graph";

const tracing: TransportInterceptor = {
  onRequest: (req) => {
    const headers = new Headers(req.headers);
    headers.set("x-request-id", crypto.randomUUID());
    return { ...req, headers };
  },
  onResponse: (req, res) => {
    console.log(`${req.method} ${req.url} -> ${res.status}`);
    return res;
  }
};

const transport = createFetchTransport({ interceptors: [tracing] });

Ordering:

  • onRequest hooks run in array order before fetch is invoked. Each hook may mutate the request by returning a new TransportRequest (plain objects are fine).
  • onResponse hooks run in array order after fetch resolves. Each hook may return a replacement TransportResponse.

Interceptors can be async; the chain awaits each hook. For errors originating inside fetch, the createFetchTransport catches the throw and re-throws it as GraphNetworkError with cause preserved — interceptors do not see the raw fetch error.

Why a separate testing subpath?

createMockTransport lives at @wats/graph/testing, not the package root. Production code that imports @wats/graph never pulls the mock in. Tests import @wats/graph/testing explicitly, which keeps the dependency graph honest and keeps mocks out of production bundles.

On this page