@vibedout/provider-kit
v0.1.1
Published
The shared plumbing for provider adapters (payments, shipping, maps and the like): an HTTP client with timeouts, abort chaining, secret redaction, status classification and opt-in retries for safe calls; an error-family factory with one shared code vocabu
Maintainers
Readme
@vibedout/provider-kit
The shared plumbing for provider adapters (payments, shipping, maps, anything that talks to a
third party's API). An adapter is createHttpClient plus a mapError hook plus its own domain
mappers; none of them writes its own timeout, retry, redaction, status mapping or error base class.
- No dependencies, and no Node. It is
fetch,AbortController,HeadersandURLSearchParams. - ESM only, with types. Each module is also a subpath (
@vibedout/provider-kit/http) for a narrower import. The types use the standardfetchglobals (the DOM lib or@cloudflare/workers-types). - Crypto (HMAC, webhook verification, receipts) is
@vibedout/web-crypto; money is@vibedout/money. Nothing here needs either.
import { createHttpClient, defineErrorFamily, assertIdempotencyKey } from "@vibedout/provider-kit";An adapter, end to end
import { createHttpClient, defineErrorFamily, readString } from "@vibedout/provider-kit";
// 1. One error family: the shared codes plus the domain's own.
const PaymentsFamily = defineErrorFamily<"card_declined" | "refund_not_supported">("PaymentsError", ["timeout", "unavailable"]);
export class PaymentsError extends PaymentsFamily {}
// 2. One HTTP client: base URL, credentials, every secret that must never appear in an error.
const http = createHttpClient({
provider: "stripe",
baseUrl: "https://api.stripe.com",
auth: () => ({ authorization: `Bearer ${secretKey}` }),
secrets: [secretKey, webhookSecret],
ErrorClass: PaymentsError,
timeoutMs: 20_000,
// 3. The provider's own error body, mapped to the domain's codes. Return nothing to take the default.
mapError: ({ status, body }) => {
if (readString(body, "error", "type") === "card_error") {
return new PaymentsError("card_declined", readString(body, "error", "message") ?? "declined", { status });
}
},
});
// 4. Calls.
const { body } = await http.request({
method: "POST",
path: "/v1/refunds",
body: { form: { payment_intent: id } },
idempotencyKey: `${paymentId}:refund`, // checked, then sent as `Idempotency-Key`
mutating: true,
signal, // the caller's abort signal, chained to the timeout
});http
createHttpClient({ provider, baseUrl, auth?, fetch?, timeoutMs?, secrets, ErrorClass?, mapError?, retry?, headers?, ambiguousNote? })
and .request({ method, path, query?, body?, headers?, idempotencyKey?, idempotencyHeader?, mutating?, signal?, timeoutMs? }).
- Body:
{ json },{ form }(an object is bracket-encoded,line_items[0][price]=p; a string goes as it is) or{ text, contentType? }.queryis bracket-encoded too. A GET with a body throws. - Response:
{ status, headers, body, text, isJson }. A body that is not JSON comes back as text withisJson: false; an empty one asundefined. A 2xx is returned, any other status throws. - Timeout (15 seconds unless set; a value that is not above 0 and at most 2147483647 ms is refused) covers the whole call, body included. It cuts a
fetchthat ignores its signal too. - Redirects are not followed by default (
redirect: "manual"): a 3xx is aninvalid_response, so a credential header cannot be carried to another host. Passredirect: "follow"if a provider needs it. - Abort: the caller's signal is chained to the timeout, its listener is removed when the call ends, and a signal that is already aborted sends nothing. A caller's abort is
unavailableand never retryable: giving up is the caller's decision. - Redaction: every secret in
secrets(and its URL-encoded form), and anyBearer ...orBasic ...credential, is removed from the message and stack of every error the client throws, the wholecausechain (anError, a plain object with amessage, a string), theproviderCodeand theissues, and from the ones yourmapErrorbuilds. Text is redacted before it is clipped, so a secret cut in half by the clip cannot leak a prefix.http.excerpt(body)andhttp.redact(text)do the same for text you put into a message yourself. mapErroris called for every response, 2xx included (Envia answers some errors with a 200). Return an error to throw it, or nothing for the default.- A bad header value (a key with a newline in it) is
configuration_invalid, not a network error.
Status to error code
classifyStatus(status, headers) gives { code, retryable, retryAfterMs }:
| Status | Code | Retryable |
|---|---|---|
| 401, 403 | auth_failed | no |
| 404 | not_found | no |
| 408 | timeout | yes |
| 429 | rate_limited (honours Retry-After) | yes |
| 423, 424, 5xx | unavailable | yes |
| 3xx | invalid_response | no |
| any other 4xx (402 and 409 included) | invalid_request | no |
A provider that gives 402 or 409 a meaning (an empty balance, an already-processed payment) says so in mapError.
Writes that may have happened
A write that fails with a timeout, a dropped connection or a 5xx may already have been applied. mutating defaults to true for every method but GET, so a write that forgets the flag is treated as the dangerous kind; a POST that only reads (a rate quote, a search) says mutating: false. For a mutating call:
- no idempotency key: any failure that is not a 429 is reported
retryable: false, whatever built the error (the client or yourmapError), withambiguousNoteappended ("The operation may already have been applied: check the provider before repeating it"). Reconcile first. - with an idempotency key: the provider dedupes a repeat, so the failure stays retryable.
- a 429 is always retryable: it was refused before it did anything.
- a call that never reached the network (the caller's signal was already aborted) says nothing about the outcome.
Retries
Off by default. retry: { attempts, backoff?, maxDelayMs?, sleep? } retries only a GET or a write that carries an idempotency key, and only an error that is retryable. attempts counts the first try, is at most 10, and anything that is not a finite number is one try. The wait is the Retry-After the provider sent, else backoff(attempt) (250 ms doubling to 4 s); a wait longer than maxDelayMs (10 s) is not waited for, and the error is thrown instead. The caller's abort stops it.
errors
defineErrorFamily<ExtraCodes>(name, retryableCodes) builds a ProviderError subclass with code, retryable, status?, providerCode?, retryAfterMs?, issues and cause. The shared codes:
timeout, unavailable, rate_limited, auth_failed, configuration_invalid, invalid_request, invalid_response, not_found, unsupported_operation, webhook_signature_invalid, webhook_expired, validation_failed.
retryable says whether the same call may succeed if repeated unchanged. It never means "repeat a purchase that might already have happened".
Everything else
| Module | What |
|---|---|
| body | readCapped(stream, max) (a body read with a ceiling: it stops at the first chunk that goes past the cap and cancels the stream, whatever the body declared) and BodyTooLargeError |
| json | isPlainObject, readString(body, ...path), str (non-empty string), idStr (string or finite number as text), num, errorMessageOf(body) (Stripe, Envia, Mercado Pago and queries shapes), deepMerge (drops __proto__, constructor, prototype) |
| time | isoFromZoned (a timestamp that states its zone, as ISO UTC; refuses one that does not, because Date.parse would read it in the machine's zone), zonedMs, hasZone, isoFromSeconds, DAY_MS and friends |
| idempotency | assertIdempotencyKey(key, { maxLength?, ErrorClass? }): present, at most 255 characters, printable ASCII (it travels as a header) |
| validation | ValidationIssue, ValidationResult, ok, fail, validationError(what, issues, ErrorClass?) |
| retry | expBackoff(attempt, { baseMs, capMs, jitter?, random? }) (min(baseMs * 2 ** attempt, capMs), zero-based, optionally with full jitter), sleep(ms, signal?) |
| deadline | fetchWithTimeout(input, init, { timeoutMs, fetch? }), withDeadline(work, ms, expired), abortAfter(ms, { signal?, reason? }): see below |
Deadlines
createHttpClient is the client for a provider's JSON API. For everything else that must not wait for ever there are three small
functions in @vibedout/provider-kit/deadline:
import { abortAfter, fetchWithTimeout, withDeadline } from "@vibedout/provider-kit/deadline";
// one HTTP call that gives up: rejects with a TimeoutError DOMException (name "TimeoutError"), aborts the request, and covers the body too
const response = await fetchWithTimeout(url, { method: "POST", body, signal: callerSignal }, { timeoutMs: 5_000, fetch: deps.fetch });
// any promise: the caller says what to reject with (and may clean up in the same function)
const menu = await withDeadline(binding.getMenu(args), 1_500, () => new Error("menu service: no answer in 1500 ms"));
// a signal for a call that takes one; `done()` in a finally
const deadline = abortAfter(30_000, { signal: outer, reason: () => new Error("timeout") });
try { await model.run(input, { signal: deadline.signal }); } finally { deadline.done(); }They are a leaf (no imports, only AbortController and setTimeout), so a package or template that cannot depend on this one carries a
generated copy of the file. They do not use AbortSignal.timeout: it is missing from phones older than 2022, a fetch that ignores
its signal would still hang, and a test cannot move it with fake timers. fetchWithTimeout rejects at the deadline even then.
testing
@vibedout/provider-kit/testing is for tests only and is not on the main barrel.
import { recordedFetch, jsonResponse, hangUntilAborted } from "@vibedout/provider-kit/testing";
const { fetch, calls } = recordedFetch([
jsonResponse({ id: "pi_1" }), // answers in order
{ status: 503, body: { message: "down" } }, // or describes a response
new TypeError("fetch failed"), // or throws as a network failure
hangUntilAborted, // or never answers: for timeout and abort tests
]);
const http = createHttpClient({ provider: "x", baseUrl, fetch, secrets: [] });
// calls[0] is { url, method, headers (lower-cased), body, json, form, signal }A call nobody expected throws, naming it. A function fixture decides per call.
Testing this package
pnpm test (vitest). The status mapping is checked for every status from 100 to 599, the request and the
redaction against a recorded fetch, and the small helpers (JSON readers, time, idempotency keys, form
encoding, deadlines) against fixed expected values.
