Failure reasons
The one error type DomainKit raises, the sixteen reasons it carries, the HTTP status each answers with, and what a host should do about each of them.
Every DomainKit operation fails with one DomainKit.Error. It carries exactly one Reason, and
category, isRetryable, and httpStatus all derive from that reason. Hosts match on the reason;
nothing parses a message.
import { DomainKit, Reason } from "domainkit";
The reasons
| Reason | Status | Retryable | Carries | Next step |
|---|---|---|---|---|
InvalidInput |
400 | no | message, field |
Fix the request |
Unauthenticated |
401 | no | message |
Sign in, or supply a working credential |
Forbidden |
403 | no | message |
The credential lacks the permission |
Reconnect |
403 | no | provider, connectionId |
Ask the customer to connect the provider again |
NotFound |
404 | no | entity, id |
The row or provider object does not exist for this owner |
Conflict |
409 | no | planId, operations |
Show the conflicting records; approve partially or fix DNS |
Stale |
409 | no | planId, digest |
The zone moved under the plan; build a new one |
Expired |
409 | no | entity, id |
The plan, approval, continuation, or credential aged out |
Busy |
409 | yes | key |
Another apply or refresh holds the lock; retry |
ProviderConflict |
409 | no | provider, code, message |
The provider refused because a conflicting record exists |
Unsupported |
501 | no | provider, operation, message |
The provider or that target cannot do it |
ProviderRejected |
502 | no | provider, code, message |
The provider refused; the message says why |
ProviderUnavailable |
503 | yes | provider, retryAfterMs |
Rate limited or down; retry after the delay |
StorageFailed |
500 | yes | operation, message |
The database call failed; retry |
CryptoFailed |
500 | no | operation |
Sealing or opening failed; check the custody key |
ResolverFailed |
500 | no | resolver, message |
The DNS pool could not be reached |
Categories
category groups the reasons for logging and metrics: request, auth, plan, provider,
storage, internal.
Matching
/** Every failure is one `DomainKit.Error`; the reason says what to do next. */
export const explain = applyAndSummarise.pipe(
Effect.catchTag("DomainKitError", (error) =>
Match.value(error.reason).pipe(
Match.tag("Conflict", ({ operations }) =>
Effect.succeed(`Fix ${operations.length} conflicting record(s) first`),
),
Match.tag("Stale", () => Effect.succeed("The zone moved; build a new plan")),
Match.tag("Expired", () => Effect.succeed("The plan aged out; build a new one")),
Match.orElse(() => Effect.succeed(error.message)),
),
),
);DomainKit.isError(value) narrows an unknown to the error, for a catch at a foreign boundary.
Over the wire
domainkit/server answers with the error value itself at the status its reason derives, so a
Conflict is a 409 whose body still carries the conflicting operations and a Reconnect is a 403
naming the connection. domainkit/client decodes that body back into the same
DomainKit.Error, reason intact.
A reply the client cannot read as one came from in front of the server: a proxy, a login page, a maintenance window. Those are classified from the status instead, and always name the transport’s base URL as the origin. The transport carries that table.
In the UI
@domainkit/react renders a sentence per reason from Messages.Catalog, so nothing shows a tag.
Override one key to change one sentence.
/** A failure keeps the `DomainKit.Error`, so read `reason`, `category`, and `isRetryable`. */
export function ConnectionProblem({ domain }: { readonly domain: string }) {
const { messages } = DomainKit.useDomainKit();
const connection = Connect.useController({ domain });
const state = connection.state;
if (state._tag !== "Failure") return null;
return (
<p role="alert">
{Messages.failure(state.error, messages)}
{state.error.isRetryable ? (
<button onClick={connection.retry} type="button">
Try again
</button>
) : null}
</p>
);
}Troubleshooting covers what to do when one of these keeps happening.