Skip to content

Cookbook: requests and responses

Eugene Lazutkin edited this page Jul 10, 2026 · 5 revisions

Cookbook: Requests and responses

Inspectors are the hooks for cross-cutting concerns. io.inspect.request(fn, match?) runs after a request is prepared but before it is dispatched; io.inspect.response(fn, match?) runs after a response lands but before a bad status throws. The optional match — a URL prefix string, a RegExp, or a predicate (url) => boolean — scopes the inspector to matching request URLs. Inspectors run in registration order and can mutate in place or return a replacement.

Rewrite requests

A request inspector receives the prepared {url, method, headers, body, signal} and the resolved options. headers is a live Headers object; url is a plain string. Mutating either sticks — no return needed.

import io from 'double-meh';

io.inspect.request(request => {
  console.log(request.method, request.url); // trace every call
});

Map virtual hosts to real base URLs

Give short, environment-independent tags to your services and expand them to real origins at request time. Callers never hardcode a host, and you swap prod/staging in one place.

const bases = {
  '@api': 'https://api.example.com',
  '@auth': 'https://auth.example.com'
};

io.inspect.request(request => {
  const slash = request.url.indexOf('/');
  const tag = slash < 0 ? request.url : request.url.slice(0, slash);
  if (bases[tag]) request.url = bases[tag] + request.url.slice(tag.length);
});

await io.get('@api/users/1'); // -> https://api.example.com/users/1
await io.post('@auth/login', creds);

Inject an Authorization header for matching hosts

Attach credentials per host, and only when the caller has not already set them. Register this after any URL-rewriting inspector so the URL is already absolute when it runs.

const tokens = {
  'api.example.com': () => sessionStorage.getItem('api_token'),
  'auth.example.com': () => sessionStorage.getItem('auth_token')
};

io.inspect.request(request => {
  if (request.headers.has('authorization')) return;
  const getToken = tokens[new URL(request.url).host];
  const token = getToken && getToken();
  if (token) request.headers.set('Authorization', 'Bearer ' + token);
});

For a single origin, scope the inspector with match instead of testing inside it:

io.inspect.request(request => {
  request.headers.set('Authorization', 'Bearer ' + sessionStorage.getItem('api_token'));
}, 'https://api.example.com/');

Shape the request

Platform objects work directly: a Headers instance for options.headers, a URLSearchParams as the query (positional or options.query). A few options cover the common transport tweaks:

await io.get('https://api.example.com/search', new URLSearchParams('q=meh&limit=10'), {
  headers: new Headers({'X-Trace': '1'}),
  timeout: 5000, // ms; throws io.TimedOut when exceeded
  bust: true // append a uniquifying query parameter to defeat intermediary caches
});
  • timeout composes with your own signal and surfaces as io.TimedOut.
  • bust: true appends io-bust=<unique>; a string picks the parameter name. Busted requests are never stored in io.cache.
  • as sets the request Content-Type only when a body exists — a GET with as no longer sends a bodyless Content-Type header.
  • Positional data on a DELETE becomes the query, like the other read verbs — but an explicit options.data on a DELETE is sent as the body, an escape hatch for APIs like Elasticsearch:
await io.delete('https://search.example.com/things/_delete_by_query', null, {
  data: {query: {match: {status: 'stale'}}}
});

Control response decoding

Decoding normally follows the response Content-Type; decode overrides it — 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData', or a function (response, options) => any. onDownloadProgress(info) fires as body bytes arrive, with info = {loaded, total, lengthComputable}.

const text = await io.get(url, null, {decode: 'text'}); // keep mislabeled JSON raw

const blob = await io.get(imageUrl, null, {
  decode: 'blob',
  onDownloadProgress: ({loaded, total, lengthComputable}) => {
    if (lengthComputable) renderProgress(loaded / total);
  }
});

Note: malformed JSON on a JSON content-type throws io.FailedIO (the SyntaxError is on .cause, the response is attached); an empty body with a JSON content-type decodes to undefined.

Watch an upload

onUploadProgress(info) mirrors the download side for the request body. A stream body meters per chunk as the transport pulls it (lengthComputable: false — the total is unknowable); a buffered body stays buffered (streaming it would break HTTP/1.1 and non-Chromium browsers) and reports one completion event when the response arrives.

const upload = fileInput.files[0]; // a Blob: one event with loaded === total === blob.size
await io.put('/media/avatar', upload, {
  onUploadProgress: ({loaded, total, lengthComputable}) => {
    if (lengthComputable) renderProgress(loaded / total);
  }
});

// granular progress needs a stream body (CLI: any HTTP version; browsers: Chromium + HTTP/2)
await io.post(
  '/bulk',
  {readable: makeReadable()},
  {
    onUploadProgress: ({loaded}) => renderBytes(loaded)
  }
);

The meter counts bytes handed to the transport, after compression; mock-served requests and cache hits report nothing.

Post-process every response

A response inspector receives the envelope and the request context. Use it to unwrap an envelope-in-an-envelope, normalize shapes, or record timing. Mutating envelope.data in place is enough.

io.inspect.response(envelope => {
  const body = envelope.data;
  if (body && typeof body === 'object' && 'result' in body) {
    envelope.data = body.result; // unwrap {result: ...} into the bare payload
  }
});

io.inspect.response((envelope, ctx) => {
  for (const metric of envelope.serverTiming) {
    console.debug(ctx.key, metric.name, metric.dur);
  }
});

Centralize error handling

Wrap the verbs once and classify failures in a single place. All I/O failures extend io.IOError. io.BadStatus means the server answered with a non-2xx (it carries the full envelope — use .status / .data); io.FailedIO means the request never got an answer — offline, DNS, CORS, or a timeout (io.TimedOut, a FailedIO subclass). The original error is preserved on .cause. A user abort is never wrapped — it surfaces as the platform AbortError; test with the exported isAbort(error).

import {io, isAbort} from 'double-meh';

const request = async fn => {
  try {
    return await fn();
  } catch (error) {
    if (isAbort(error)) throw error; // the caller cancelled — not a failure
    if (error instanceof io.BadStatus) {
      if (error.status === 401 || error.status === 403) {
        location.assign('/login'); // expired or missing credentials
      }
      // error.problem is the parsed error envelope even when a legacy service mislabels it
      if (error.problem?.detail) showErrorBanner(error.problem.detail);
      throw error;
    }
    if (error instanceof io.FailedIO) {
      showOfflineBanner(); // no response — treat as offline
    }
    throw error;
  }
};

const user = await request(() => io.get('@api/users/1'));

See also

Clone this wiki locally