SDK and error contracts
Walkthrough: adopting the envelope on a service
Walkthrough: adopting the envelope on a service One toy service, shelf , taken from a hand rolled failure object to the fleet envelope, end to end: guard before, migrate, guard after, then real requests against its edge with the captured an
Walkthrough: adopting the envelope on a service
One toy service, shelf, taken from a hand-rolled failure object to the
fleet envelope, end to end: guard before, migrate, guard after, then real
requests against its edge with the captured answers. Every command and
every output block below was executed against this repository. The service
is JavaScript; the same walk holds for the other runtimes with the
per-runtime pages (js, python,
rust, swift).
The service before
shelf reads objects from its store and, when the store is unreachable,
answers with a failure object it wrote itself — the state most of the
fleet was in, six times over:
// serve.mjs, before adoption
import { createServer } from 'node:http';
const STORE = 'http://127.0.0.1:19799';
const server = createServer(async (request, response) => {
try {
const upstream = await fetch(`${STORE}/object`);
response.writeHead(upstream.status, { 'content-type': 'application/json' });
response.end(await upstream.text());
} catch (error) {
response.writeHead(500, { 'content-type': 'application/json' });
response.end(JSON.stringify({
failure_point: 'shelf.store.read',
error_code: 'store_down',
severity: 'error',
detail: String(error),
}) + '\n');
}
});
server.listen(9799, '127.0.0.1');
Three defects travel in that one literal, and none of them is a typo:
store_down is a code nobody can look up, severity: 'error' is chosen at
the call site (the catalogue says an unreachable dependency is critical),
and 500 is a status ladder of length one.
Step 1: the guard names the fork
$ node ci/no-handrolled-envelope.mjs /tmp/toy-shelf
2 site(s) name an envelope key outside the package:
/tmp/toy-shelf/serve.mjs:14 failure_point: 'shelf.store.read',
/tmp/toy-shelf/serve.mjs:15 error_code: 'store_down',
Each needs a reason. Building an envelope here means the derived fields can be
wrong, so build it with wisent-errors instead. Three answers are legitimate and
common: an operator-visible log line whose format is already parsed, a field
declaration, and another API's own `error_code` -- Azure has one, and this guard
cannot tell it from ours. Read the line before believing the count.
Exit 1. Both sites here are the real thing, not false positives: a literal
that writes failure_point and error_code outside the package.
Step 2: install and migrate
For this run the checkout was linked into node_modules/@wisent/errors
(npm install ../wisent-errors produces the same layout); a real consumer
pins a commit — "@wisent/errors": "github:wisent-ai/wisent-errors#<sha>"
(quick-start). The service after:
// serve.mjs, after adoption
import { createServer } from 'node:http';
import { failure, failureOrFallback, render } from '@wisent/errors';
import { httpStatus, fromUpstreamStatus } from '@wisent/errors/codes';
const STORE = 'http://127.0.0.1:19799';
const SERVICE = 'shelf';
function answer(response, envelope) {
console.error(render(envelope));
response.writeHead(httpStatus(envelope.error_code), { 'content-type': 'application/json' });
response.end(JSON.stringify(envelope) + '\n');
}
const server = createServer(async (request, response) => {
const url = new URL(request.url, `http://${SERVICE}`);
if (url.pathname !== '/api/object') {
return answer(response, failure({
failurePoint: 'shelf.http.route',
code: 'not_found',
service: SERVICE,
detail: `no route for ${url.pathname}`,
}));
}
let upstream;
try {
upstream = await fetch(`${STORE}/object`);
} catch (error) {
// The store did not answer at all. Nothing here chooses a status or a
// severity; the code does. The salvage builder, because this is an error
// path and an error path must not throw.
return answer(response, failureOrFallback({
failurePoint: 'shelf.store.read',
code: 'infra_down',
service: SERVICE,
impact: 'one object read',
detail: error.cause?.message ?? error.message,
}));
}
if (!upstream.ok) {
// The store answered with a failure status: classify it, quote its words.
return answer(response, failure({
failurePoint: 'shelf.store.read',
code: fromUpstreamStatus(upstream.status),
service: SERVICE,
impact: 'one object read',
detail: `${upstream.status} -- ${(await upstream.text()).trim()}`,
}));
}
response.writeHead(200, { 'content-type': 'application/json' });
response.end(await upstream.text());
});
server.listen(9799, '127.0.0.1', () => console.error('shelf: listening on 127.0.0.1:9799'));
What moved where, per integration: the failure points
stayed (they are shelf's registry); the code, severity, status, and retry
verdict now come from the catalogue; the store's own words go into
detail verbatim; the error path uses the salvage builder so it cannot
throw; one answer helper is the whole emitter.
Step 3: the guard again
$ node ci/no-handrolled-envelope.mjs /tmp/toy-shelf
no hand-built envelope in /tmp/toy-shelf
Exit 0. The service no longer writes an envelope key anywhere: the builders do.
Step 4: the store is down — 503, infra_down
With nothing listening on the store's port:
$ curl -is http://127.0.0.1:9799/api/object
HTTP/1.1 503 Service Unavailable
content-type: application/json
{"failure_point":"shelf.store.read","error_code":"infra_down","service":"shelf","impact":"one object read","severity":"critical","retryable":true,"outage":true,"detail":"connect ECONNREFUSED 127.0.0.1:19799"}
The edge status is httpStatus('infra_down') = 503, not a hand-chosen
500; the severity is the catalogue's critical, not the hand-chosen
error; the detail is what the socket layer actually said. The service's
own log got the rendered line:
infrastructure we depend on is unreachable — our failure; retry later {"failure_point":"shelf.store.read","error_code":"infra_down","service":"shelf","impact":"one object read","severity":"critical","retryable":true,"outage":true,"detail":"connect ECONNREFUSED 127.0.0.1:19799"}
Step 5: the store throttles — 429, rate_limit
With the store answering 429:
$ curl -is http://127.0.0.1:9799/api/object
HTTP/1.1 429 Too Many Requests
content-type: application/json
{"failure_point":"shelf.store.read","error_code":"rate_limit","service":"shelf","impact":"one object read","severity":"warning","retryable":true,"outage":false,"detail":"429 -- slow down: shelf quota exhausted for this minute"}
fromUpstreamStatus(429) classified the store's answer as rate_limit,
httpStatus('rate_limit') answered 429 at shelf's own edge, and the
store's words crossed both hops uninvented. The log line:
an upstream is throttling us — the request or its credentials; retry later {"failure_point":"shelf.store.read","error_code":"rate_limit","service":"shelf","impact":"one object read","severity":"warning","retryable":true,"outage":false,"detail":"429 -- slow down: shelf quota exhausted for this minute"}
Step 6: a wrong route — 404, and the healthy path
$ curl -is http://127.0.0.1:9799/api/shelves
HTTP/1.1 404 Not Found
content-type: application/json
{"failure_point":"shelf.http.route","error_code":"not_found","service":"shelf","impact":null,"severity":"warning","retryable":false,"outage":false,"detail":"no route for /api/shelves"}
And with the store healthy, nothing of the package is on the path:
$ curl -is http://127.0.0.1:9799/api/object
HTTP/1.1 200 OK
content-type: application/json
{"id":"o-1","name":"first object"}
What this bought
Every failure the service can now emit is one of seven codes anyone can look up; the statuses at its edge come from one table shared by the whole fleet; its log lines are grep-able render lines carrying the JSON; and the guard keeps the next hand-rolled literal out of review. The rules behind each step: envelope, http-status-and-exit-code, integration; the tools: reference/tools.