SDK › Errors and retries
Errors and retries
Every failure — a 404, a refused connection, a cancelled call, a key that was
never configured — arrives the same way: as error, with data null.
const { data, error } = await propgate.domains.get("019fcf7a-...");
if (error !== null) {
error.code; // "not_found"
error.message; // "no such domain" — the API's own words, not a status line
error.statusCode; // 404, or 0 when there was never a response
error.retryAfterSeconds; // set on "rate_limited", undefined otherwise
}message is the API's own sentence, which is written to be acted on rather
than parsed: active key limit of 50 reached, and you hold 50; revoke one before creating another names the budget, the limit and the ask. Switch on code, log
message.
Codes
error.code is a union, and a public contract in the same way the
diagnosis taxonomy is: adding a member is additive,
changing or removing one is a breaking change.
| Code | Status | What happened |
|---|---|---|
invalid_request | 400, 422 | The request was refused. message names the field. Re-sending it unchanged will be refused again |
unauthorized | 401 | No key, a revoked key, or one that never existed |
forbidden | 403 | Authenticated, and not allowed |
not_found | 404 | No such resource for this account — the same answer another tenant's id gets, so a wrong id cannot confirm that it exists somewhere |
conflict | 409 | Registering a name that already exists, or revoking your last active key |
rate_limited | 429 | Carries retryAfterSeconds |
server_error | 5xx | Ours |
api_error | any | A status this client has no more specific name for |
timeout | — | No response inside timeoutMs |
connection_error | — | The request never arrived: DNS, TLS, a refused connection, or a body that stopped mid-response |
aborted | — | The AbortSignal you passed fired |
invalid_response | — | Something answered and it was not this API — a proxy error page, a captive portal, a tunnel that is down |
invalid_option | — | An option this client cannot use, such as a timeoutMs of NaN. Nothing was sent |
missing_api_key | — | An authenticated call with no key configured. Nothing was sent |
The last five never reached the API, which is why statusCode is 0 on them.
const { data, error } = await propgate.domains.create({
name: "yourdomain.dev",
profile: "sending",
});
switch (error?.code) {
case undefined:
return data;
case "conflict":
// Already registered under a different external id.
return await propgate.domains.list({ externalId: "cust_1" });
case "invalid_request":
// The message names the field. Log it; retrying unchanged will not help.
throw new Error(error.message);
case "rate_limited":
return schedule(error.retryAfterSeconds ?? 60);
default:
throw error;
}If you would rather throw
PropgateError extends Error, so a wrapper of four lines gets you the other
style everywhere, and keeps a stack:
import { PropgateError } from "@propgate/sdk";
async function must<T>(call: Promise<{ data: T | null; error: PropgateError | null }>) {
const { data, error } = await call;
if (error !== null) {
throw error;
}
return data as T;
}
const domain = await must(propgate.domains.get("019fcf7a-..."));What gets retried
Connection failures, timeouts, 429s and 5xx are retried — twice by default,
250ms then 500ms apart.
No POST that may already have been applied is ever repeated.
POST /v1/api-keys mints a key every time it is called, so a retry after a
timeout is a second key nobody knows about. POST /v1/domains and
POST /v1/webhooks are idempotent by construction, but that is a property of
those two routes rather than of the method, and a rule that has to be right per
route is one that will be wrong the first time a route is added.
A 429 is the exception, and the only one: the server refused before doing
anything, so repeating the request cannot repeat an effect.
// Two retries on top of the first attempt, and none of them on a POST that
// may already have been applied.
const propgate = new Propgate(process.env.PROPGATE_API_KEY, { maxRetries: 2 });
// Turn them off entirely when your own queue is the thing that retries.
const once = new Propgate(process.env.PROPGATE_API_KEY, { maxRetries: 0 });Rate limits you have to schedule around
A Retry-After longer than five seconds is not waited out inside the call.
const { data, error } = await propgate.domains.check(id);
if (error?.code === "rate_limited") {
// Anything short the client already waited out. This one outlasted it.
await enqueueRetryIn(error.retryAfterSeconds ?? 60);
}Verify is limited to 100 checks a minute per account and
answers Retry-After: 47 when you cross it. Honouring that inside the client
would turn one await into a 47-second stall you cannot see, twice over at the
default maxRetries. Past the ceiling the limit comes back as an error carrying
the number, and how long your process may block is your decision.
In practice the ceiling is 90.75 seconds: waiting the full five needs a
Retry-After that long, and a request that timed out never carries one, so
those two waits are the 250ms and 500ms backoff instead.
Aborting mid-backoff returns aborted immediately rather than at the end of the
wait, so a cancelled request stops being your problem when you cancel it.