ts / src / errors.ts
679 words
  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';

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/ts.git