Skip to content

Cookbook: multiple apis

Eugene Lazutkin edited this page Jul 10, 2026 · 1 revision

Cookbook: Multiple APIs

Real apps rarely talk to one service. There are two ways to give each API its own settings, and they compose: scoped configuration on the shared instance for different settings, and isolated instances (io.create()) for different machinery. Start with the first; reach for the second when state must not be shared.

One shared instance, scoped configuration

Cache and dedup keys are URL-scoped, so two APIs cannot collide in the shared cache — different settings do not require different instances. Configure each API once at setup; call sites stay one-liners.

import io from 'double-meh';

// per-API option defaults — the lowest merge layer (per-call options always win)
io.defaults('https://legacy.example.com/', {
  listSeparator: ',', // this API wants ?tags=a,b
  timeout: 10_000, // and it is slow
  accept: 'application/json'
});
io.defaults('https://modern.example.com/', {
  accept: 'application/vnd.modern+json'
});

// per-API auth — inspectors scoped by URL prefix (RegExp and predicates work too)
io.inspect.request(request => {
  request.headers.set('Authorization', 'Bearer ' + legacyToken());
}, 'https://legacy.example.com/');
io.inspect.request(request => {
  request.headers.set('X-Api-Key', modernKey());
}, 'https://modern.example.com/');

// per-API service defaults — theDefault predicates
io.cache.theDefault = options => !String(options.url).startsWith('https://volatile.example.com/');

// per-API mocks in tests — the matcher scopes them (trailing * = prefix; bare string = exact)
io.mock('https://legacy.example.com/*', () => new Response('{"ok":true}'));
// call sites don't know or care which API has which conventions
const units = await io.get('https://legacy.example.com/units', {tags: ['a', 'b']}); // ?tags=a,b
const items = await io.get('https://modern.example.com/items'); // vendor accept, no separator

Endpoint descriptors are the per-endpoint rung of the same ladder — a reusable options bag pins the URL and any per-endpoint settings, and every verb shares it.

Isolated instances — io.create()

io.create() returns a fresh, fully-equipped instance: its own transports, inspectors, defaults, data/MIME processors, mimeTypes aliases, encoders, events, and services with their own state — own cache storage, own mock registry, own bundler config. It is a clean slate: nothing of the parent's configuration is inherited.

Use it when an API needs different machinery, not just different settings:

import io from 'double-meh';
import {sqliteStorage} from 'double-meh/storage/sqlite.js';
import {installAcme} from './acme-plugin.js';

const legacy = io.create();
installAcme(legacy); // an envelope plugin that unwraps this API's {v, payload} envelopes
legacy.defaults({listSeparator: null, timeout: 10_000}); // unscoped: the whole instance is one API
legacy.cache.storage = await sqliteStorage(); // its own persistent cache

const modern = io.create(); // sees none of the above
modern.bundle.url = 'https://modern.example.com/bundle'; // its own bundler window

The clearest reason to isolate: a pluggable envelope that matches a shared MIME type (plain application/json) would hijack decoding for every service on the instance — containment is isolation. The same goes for a different cache backend, a different default transport, or SDK-grade separation where independent consumers must not see each other's inspectors, mocks, or events.

Errors are shared classes

Every instance carries the same error classes — io.IOError, io.FailedIO, io.BadStatus, io.TimedOut — so one catch block serves requests from any instance:

try {
  await Promise.all([legacy.get(legacyUrl), modern.get(modernUrl)]);
} catch (error) {
  if (error instanceof io.BadStatus) {
    // works no matter which instance threw; error.problem is the parsed envelope either way
    console.error(error.status, error.problem);
  }
}

What differs per instance is error behavior, not identity: each instance's MIME processors decide how its error bodies parse into BadStatus.problem, so an envelope plugin's fault handling stays contained to its instance.

Choosing between the patterns

Need Reach for
per-API timeouts, accept, listSeparator, retry policy io.defaults(match, bag)
per-API auth headers, URL rewriting scoped inspectors
per-host cache/track opt-outs theDefault predicates
per-endpoint settings shared across verbs an endpoint descriptor
an envelope plugin on a shared MIME type io.create()
a different cache backend / transport default / bundler io.create()
independent consumers (SDKs) that must not see each other io.create() per consumer

See also

Clone this wiki locally