Skip to content

envelope

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

Response envelope

Every io.full.* call resolves to an envelope: the parsed body plus the status, headers, and the raw Response. The same shape is mixed into thrown BadStatus errors, so a failed request is inspected exactly like a successful one.

import io from 'double-meh';

const env = await io.full.get('https://api.example.com/users/1');
env.data; // parsed body
env.status; // 200
env.ok; // true

Eager core

Four fields plus the raw response are set immediately:

  • data — the decoded body (JSON parsed for application/json and *+json content types, else text; undefined for HEAD/OPTIONS, 204, or empty bodies). Malformed JSON throws FailedIO with the SyntaxError on .cause; the decode option forces another parse mode (see Requests & options).
  • status — the numeric HTTP status.
  • ok — the Response.ok boolean.
  • headers — a plain header dict (lower-cased keys; set-cookie collected into an array).
  • response — the untouched Response for anything not modelled here.
const env = await io.full.get(url);
if (env.ok) console.log(env.status, env.headers['content-type'], env.data);

Lazy header getters

The rest are lazy getters that parse a header on access, so you only pay for what you read:

  • etag — the raw ETag, or undefined.
  • weaktrue when the ETag is weak (W/"...").
  • lastModified — a Date from Last-Modified, or undefined.
  • locationLocation resolved to an absolute URL against the response URL.
  • links — parsed Link header as {rel: url}.
  • contentType — the raw Content-Type, or undefined.
  • retryAfterRetry-After as a number of seconds, or a Date, or undefined.
  • serverTimingServer-Timing as an array of {name, dur?, desc?}.
const env = await io.full.post('https://api.example.com/things', {name: 'Bob'});
env.status; // 201
env.location; // 'https://api.example.com/things/7' (resolved absolute)
env.etag; // '"v1"' or undefined

Errors carry the envelope

Failures throw one of four classes exported as io.IOError, io.FailedIO, io.TimedOut, and io.BadStatus (also importable by name). IOError is the base — it extends Error and carries .options; every wrap preserves the original error on .cause.

  • IOError — the base class; carries .options.
  • FailedIO — an IOError; transport/network failure. Adds .response (a Response, or undefined when none arrived).
  • TimedOut — a FailedIO whose message is 'Timed out'; produced by the timeout option (milliseconds, implemented with AbortSignal.timeout composed with your signal via AbortSignal.any).
  • BadStatus — an IOError (not a FailedIO) thrown for a non-2xx response (unless ignoreBadStatus). It alone mixes in the full envelope shape — .data, .status, .ok, .headers, .response, and every lazy getter — so guard with instanceof io.BadStatus before reading them; e.data is the parsed body, typically problem+json. e.problem is the parsed error envelope regardless of how the service labels it: data itself when the body decoded structured, else a JSON sniff of a string body (legacy services mislabel their envelopes), undefined when nothing parseable arrived. No schema is imposed — RFC 9457 is the blessed convention, not a requirement; XML and other legacy types plug in via io.registerMime, whose decode already runs on error bodies.

User aborts are not errors of the library: they pass through as the platform AbortError — never wrapped, never retried. The named export isAbort(error) recognizes them (both AbortError and the TimeoutError of a raw AbortSignal.timeout).

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

try {
  await io.get('https://api.example.com/missing', null, {timeout: 5000});
} catch (e) {
  if (e instanceof io.BadStatus) {
    e.status; // 404
    e.data; // the decoded body: parsed problem+json, or raw text from a legacy service
    e.problem; // the parsed error envelope either way, e.g. {title: 'Nope'} — undefined if unparseable
    e.problem?.title; // 'Nope'
  } else if (e instanceof io.TimedOut) {
    // took longer than 5s
  } else if (isAbort(e)) {
    // cancelled by your own signal
  }
}

Pass ignoreBadStatus: true to receive the envelope instead of a throw on non-2xx.

See also

Clone this wiki locally