propgate docs

SDKErrors 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.

CodeStatusWhat happened
invalid_request400, 422The request was refused. message names the field. Re-sending it unchanged will be refused again
unauthorized401No key, a revoked key, or one that never existed
forbidden403Authenticated, and not allowed
not_found404No such resource for this account — the same answer another tenant's id gets, so a wrong id cannot confirm that it exists somewhere
conflict409Registering a name that already exists, or revoking your last active key
rate_limited429Carries retryAfterSeconds
server_error5xxOurs
api_erroranyA status this client has no more specific name for
timeoutNo response inside timeoutMs
connection_errorThe request never arrived: DNS, TLS, a refused connection, or a body that stopped mid-response
abortedThe AbortSignal you passed fired
invalid_responseSomething answered and it was not this API — a proxy error page, a captive portal, a tunnel that is down
invalid_optionAn option this client cannot use, such as a timeoutMs of NaN. Nothing was sent
missing_api_keyAn 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.