Transport and Testing
The @wats/graph Transport seam — testing with MockTransport, opt-in reliable transport, and the interceptor primer.
active · 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:
| Field | Purpose |
|---|---|
responses | FIFO queue of response specs; each request consumes one. |
defaultResponse | Used after the queue is exhausted. |
onRequest(req) | Spy called before dispatch. |
fail | Error (or (req) => Error) to throw instead of responding. Simulates fetch-level failures. |
failAfter | Threshold 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 ky-like retries, backoff, 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; per-attempt timeouts compose with a caller-provided AbortSignal via native AbortSignal.any / AbortSignal.timeout.
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. Retrying POST /messages after an ambiguous network failure or 5xx can double-send a WhatsApp message if Meta received the first request but the client lost the response. If you override retryOn to retry those POST failures, pair the request with an application idempotency strategy (for example the service-level Idempotency-Key flow). 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:
onRequesthooks run in array order before fetch is invoked. Each hook may mutate the request by returning a newTransportRequest(plain objects are fine).onResponsehooks run in array order after fetch resolves. Each hook may return a replacementTransportResponse.
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.
Related
- Client reference —
GraphClientoptions and the request pipeline. - Errors reference —
GraphNetworkErrorand the typed error hierarchy.