@gusnips/http
v0.2.0
Published
The HTTP envelope an API and its clients agree on: {data, meta} or {error:{code, message, messageKey, params, details}}. And, at /retry, whether a failed request is worth another attempt. Zero dependencies, no framework.
Maintainers
Readme
@gusnips/http
The two shapes your API answers with, written down once as types. No dependencies, and nothing tied to a framework or a platform — your server, your browser client and your SDK all compile this same file.
bun add @gusnips/httpimport type { ApiSuccess } from "@gusnips/http";
type Me = ApiSuccess<{ id: string; name: string }>;
// → { data: { id: string; name: string }; meta?: PaginationMeta }The envelope
Every route answers one of two things, and never anything else:
// 2xx
{ "data": { "id": "u_1" }, "meta": { "total": 40, "limit": 20, "offset": 0, "hasMore": true } }
// anything else
{ "error": { "code": "NOT_FOUND", "message": "No such user" } }Which means unwrapping and error-shaping happen in one place instead of in every hook.
The error half has four fields, and each does a different job
{
code: "RATE_LIMITED", // what your code switches on. Stable, machine-facing.
message: "Too many requests", // English, for logs and curl. Never your primary copy.
messageKey: "errors.rateLimited", // names the SENTENCE, so the client localizes it
params: { limit: 100 }, // fills that sentence's blanks
details: { resetAt: "2026-01-01T00:00:00Z" } // what makes the refusal actionable
}The split that matters is message versus messageKey. The server owns the condition; the
client owns the prose. That is what lets an API refuse something without knowing what language
the person reads, and details is what lets the client offer a way forward instead of a dead
end.
When a request fails validation, details is a ValidationIssue[]:
[{ path: ["items", 0, "qty"], code: "too_big", maximum: 200 }];The field, the rule, and the bound, so a form can say "at most 200" next to the right input. Never the value that was sent, the validator's own sentence, or the list of allowed values.
The codes are yours
Everything is generic over your own code union. Two codebases this came from had 30 codes and 16 codes, overlapping on nine — a code list is an API's vocabulary and belongs to it.
import { asErrorCode, isApiError, type ApiErrorBody } from "@gusnips/http";
type Code = "NOT_FOUND" | "RATE_LIMITED" | "UNKNOWN";
const CODES = ["NOT_FOUND", "RATE_LIMITED", "UNKNOWN"] as const;
if (isApiError<Code>(body)) {
switch (asErrorCode(CODES, body.error.code, "UNKNOWN")) {
case "RATE_LIMITED": /* … */
}
}asErrorCode exists for one reason: a code you do not recognise means the server is newer than
the tab. A deploy landed ahead of the bundle someone is still running. That is not a parse
failure and must not throw, so it falls back instead.
Status codes
Write the map at the call site and let satisfies do the work:
export const ERROR_STATUS = {
NOT_FOUND: 404,
RATE_LIMITED: 429,
} as const satisfies Record<Code, number>;That one line is the point: adding a code to the union without giving it a status becomes a build error, instead of a route answering 500 for a refusal it knew how to explain.
Should this request be tried again?
@gusnips/http/retry answers that, and says how long to wait first.
import { isRetryable } from "@gusnips/http/retry";
isRetryable({ status: 503 }, { repeatable: true }); // → trueIt reads fields, not a class, so your SDK's own error type works as it is: status, code and
details from the envelope, and retryAfterSecs, the Retry-After header as a number
(parseRetryAfter reads both of the header's forms). A wait sent in details.retryAfterSecs
counts too, after the header. An error with no status got no answer at all: offline, DNS, a
dropped connection.
import { retryDelayMs, isRetryable } from "@gusnips/http/retry";
for (let attempt = 0; ; attempt++) {
try {
return await send();
} catch (error) {
if (attempt >= 2 || !isRetryable(error, { repeatable })) throw error;
await new Promise((resolve) => setTimeout(resolve, retryDelayMs(attempt, error)));
}
}| The failure | repeatable: true | repeatable: false |
| ---------------------- | ------------------ | ------------------- |
| No answer | retry | stop |
| 5xx | retry | stop |
| 408, 425, 429 | retry | retry |
| 409 with a stated wait | retry | stop |
| Any other 4xx | stop | stop |
repeatable has no default, because either default is wrong for half your calls. Pass true for a
read, or for a write that sends an idempotency key the server honours. A write that got no answer,
or a 5xx, may already have run, and sending it again can charge a card twice. A 408, 425 or 429
says the server did not run it, so those are safe to send again either way.
A 409 that states a wait is an idempotency key whose first call is still running. Sent again with the same key, it gets that first call's answer once it's done. A 409 with no wait, like a name already taken, is an answer.
Whatever the status, it stops when:
- the error's
codeis indurableCodes. A spent monthly quota clears when someone pays, not when you wait. - the body says
details.retryAfterSecs: null. That is the server saying no wait will clear it. - the stated wait is longer than
maxWaitSecs, 10 seconds by default. Show the wait to the person instead of holding a spinner for a minute.
retryDelayMs waits as long as the server asked, and never less than a backoff of about 1, 2, 4
seconds and so on, up to 30. Each backoff is picked at random between half and one and a half times
that, so clients cut off by the same outage do not all come back in the same second. A wait the
server stated is never moved.
@gusnips/react retries react-query on this same rule, so an SDK and the app that uses it make
the same call.
MIT · part of frontkit
