1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129 | /**
* Every failure raised by this client is a plain `Error` tagged with a
* `kind`, so callers narrow with the type guards below (or on `kind`):
*
* try { await domains.check(client, 'x.ro') }
* catch (e) { if (isApiError(e) && e.status === 402) ... }
*/
export type ApiError = Error &
Readonly<{
kind: 'api';
/** Machine-readable error code from the API, when present. */
code: string | null;
/** HTTP status of the failed response. */
status: number;
/** The parsed error body, when it was JSON. */
response: unknown;
}>;
export type TransportError = Error & Readonly<{ kind: 'transport'; detail: unknown }>;
export type ConfigError = Error & Readonly<{ kind: 'config'; detail: unknown }>;
export type DomainDeniedError = Error &
Readonly<{
kind: 'domainDenied';
/** The normalized domain that was denied, or null when it could not be resolved. */
domain: string | null;
/** The deny/allow pattern that decided, or null when denied by an allow-list miss. */
matchedPattern: string | null;
/** The operation that was blocked, e.g. "POST /domains/registrations". */
op: string;
}>;
export type TimeoutError = Error & Readonly<{ kind: 'timeout'; last: unknown }>;
export type JobFailedError = Error & Readonly<{ kind: 'jobFailed'; job: unknown }>;
export type UploadFailedError = Error & Readonly<{ kind: 'uploadFailed'; upload: unknown }>;
export type IrdError =
| ApiError
| TransportError
| ConfigError
| DomainDeniedError
| TimeoutError
| JobFailedError
| UploadFailedError;
export type ErrorKind = IrdError['kind'];
const NAMES = {
api: 'IrdApiError',
transport: 'IrdTransportError',
config: 'IrdConfigError',
domainDenied: 'IrdDomainDeniedError',
timeout: 'IrdTimeoutError',
jobFailed: 'IrdJobFailedError',
uploadFailed: 'IrdUploadFailedError',
} as const satisfies Record<ErrorKind, string>;
const tagged = <E extends IrdError>(
message: string,
fields: Omit<E, keyof Error>,
cause?: unknown,
): E => {
const error = cause === undefined ? new Error(message) : new Error(message, { cause });
return Object.assign(error, { name: NAMES[fields.kind] }, fields) as unknown as E;
};
/** Error returned by the ird API (non-2xx HTTP status). */
export const apiError = (
message: string,
code: string | null,
status: number,
response: unknown,
): ApiError => tagged<ApiError>(message, { kind: 'api', code, status, response });
/** Low-level failure talking to the API (connection failure, non-JSON body, ...). */
export const transportError = (message: string, detail: unknown, cause?: unknown): TransportError =>
tagged<TransportError>(message, { kind: 'transport', detail }, cause);
/** Invalid client configuration or call arguments. */
export const configError = (message: string, detail: unknown): ConfigError =>
tagged<ConfigError>(message, { kind: 'config', detail });
/**
* Client-side domain gate violation: the operation targets a domain that the
* client's deniedDomains/allowedDomains configuration forbids. Thrown before
* any request reaches the API.
*/
export const domainDenied = (
domain: string | null,
matchedPattern: string | null,
op: string,
): DomainDeniedError =>
tagged<DomainDeniedError>(`Domain gate: ${domain ?? '(unknown domain)'} may not be touched by ${op}`, {
kind: 'domainDenied',
domain,
matchedPattern,
op,
});
/** A polling helper ran past its deadline; `last` is the last response seen. */
export const timeoutError = (message: string, last: unknown): TimeoutError =>
tagged<TimeoutError>(message, { kind: 'timeout', last });
/** An async job reached a terminal failed status. */
export const jobFailed = (message: string, job: unknown): JobFailedError =>
tagged<JobFailedError>(message, { kind: 'jobFailed', job });
/** An IPFS upload failed or expired. */
export const uploadFailed = (message: string, upload: unknown): UploadFailedError =>
tagged<UploadFailedError>(message, { kind: 'uploadFailed', upload });
const KINDS: ReadonlySet<string> = new Set(Object.keys(NAMES));
export const isIrdError = (e: unknown): e is IrdError =>
e instanceof Error && KINDS.has((e as { kind?: unknown }).kind as string);
export const isApiError = (e: unknown): e is ApiError => isIrdError(e) && e.kind === 'api';
export const isTransportError = (e: unknown): e is TransportError => isIrdError(e) && e.kind === 'transport';
export const isConfigError = (e: unknown): e is ConfigError => isIrdError(e) && e.kind === 'config';
export const isDomainDeniedError = (e: unknown): e is DomainDeniedError =>
isIrdError(e) && e.kind === 'domainDenied';
export const isTimeoutError = (e: unknown): e is TimeoutError => isIrdError(e) && e.kind === 'timeout';
export const isJobFailedError = (e: unknown): e is JobFailedError => isIrdError(e) && e.kind === 'jobFailed';
export const isUploadFailedError = (e: unknown): e is UploadFailedError =>
isIrdError(e) && e.kind === 'uploadFailed';
|