@aoxborrow/registrar-client 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aoxborrow/registrar-client",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "One consistent, fully-typed TypeScript interface to many domain registrar APIs (GoDaddy, Cloudflare, Namecheap, Spaceship, Dynadot, Porkbun, and more) behind a single pluggable multi-provider client",
5
5
  "author": {
6
6
  "name": "Aaron Oxborrow",
@@ -89,20 +89,20 @@
89
89
  },
90
90
  "devDependencies": {
91
91
  "@eslint/js": "^10.0.1",
92
- "@types/node": "^26.2.0",
93
- "@typescript-eslint/eslint-plugin": "^8.56.0",
94
- "@typescript-eslint/parser": "^8.56.0",
92
+ "@types/node": "^26.6.2",
93
+ "@typescript-eslint/eslint-plugin": "^8.71.1",
94
+ "@typescript-eslint/parser": "^8.71.1",
95
95
  "@vitest/coverage-v8": "^4.1.11",
96
- "dotenv": "^17.4.2",
97
- "eslint": "^10.9.0",
96
+ "dotenv": "^18.0.1",
97
+ "eslint": "^10.11.0",
98
98
  "eslint-config-prettier": "^10.1.8",
99
99
  "eslint-import-resolver-typescript": "^4.4.4",
100
100
  "eslint-plugin-import-x": "^4.17.1",
101
- "globals": "^17.11.0",
102
- "prettier": "^3.6.2",
101
+ "globals": "^17.12.0",
102
+ "prettier": "^3.9.8",
103
103
  "tsup": "^8.5.0",
104
104
  "typescript": "^5.9.3",
105
- "typescript-eslint": "^8.56.0",
105
+ "typescript-eslint": "^8.71.1",
106
106
  "vitest": "^4.1.11"
107
107
  },
108
108
  "dependencies": {
package/src/errors.ts CHANGED
@@ -75,11 +75,79 @@ export class TimeoutError extends RetryableRegistrarError {
75
75
  // network/connection failure reaching the registrar API
76
76
  export class ConnectionError extends RetryableRegistrarError {
77
77
  public status = 503; // Service Unavailable
78
+ // true when the failure is known to have happened before the request left:
79
+ // DNS, connection refused, TLS handshake, or a proxy that never opened its
80
+ // tunnel. The registrar cannot have acted on it, so it is safe to re-send
81
+ // even for a write.
82
+ public notSent: boolean;
78
83
 
79
- constructor(message: string) {
84
+ constructor(message: string, details: { notSent?: boolean } = {}) {
80
85
  super(message);
81
86
  this.name = 'ConnectionError';
87
+ this.notSent = details.notSent ?? false;
88
+ }
89
+ }
90
+
91
+ // A write failed in a way that leaves its result unknown: the request timed
92
+ // out, the connection dropped mid-response, or the registrar answered 5xx. It
93
+ // may have been applied. Never retried by the library; re-read the domain
94
+ // before trying again.
95
+ export class OutcomeUnknownError extends RegistrarError {
96
+ public status = 504; // Gateway Timeout
97
+ public feature?: string;
98
+ public override cause?: unknown;
99
+
100
+ constructor(message: string, details: { feature?: string; cause?: unknown } = {}) {
101
+ super(message);
102
+ this.name = 'OutcomeUnknownError';
103
+ this.feature = details.feature;
104
+ this.cause = details.cause;
105
+ }
106
+ }
107
+
108
+ const NOT_SENT = Symbol.for('registrar-client.notSent');
109
+
110
+ // For custom `fetch` transports: mark an error as having occurred before the
111
+ // request was sent (proxy unreachable, proxy auth refused, tunnel TLS failed).
112
+ // Returns the same error for `throw markNotSent(err)`.
113
+ export function markNotSent<T>(error: T): T {
114
+ if (typeof error === 'object' && error !== null) {
115
+ Object.defineProperty(error, NOT_SENT, { value: true, enumerable: false });
116
+ }
117
+ return error;
118
+ }
119
+
120
+ // error codes Node's fetch (undici) reports, via `error.cause.code`, for
121
+ // failures that happen before any request bytes are written
122
+ const PRE_SEND_CODES = new Set([
123
+ 'ENOTFOUND',
124
+ 'EAI_AGAIN',
125
+ 'ECONNREFUSED',
126
+ 'EHOSTUNREACH',
127
+ 'ENETUNREACH',
128
+ 'EADDRNOTAVAIL',
129
+ 'UND_ERR_CONNECT_TIMEOUT',
130
+ 'CERT_HAS_EXPIRED',
131
+ 'CERT_NOT_YET_VALID',
132
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
133
+ 'SELF_SIGNED_CERT_IN_CHAIN',
134
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
135
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
136
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
137
+ ]);
138
+
139
+ // Whether a fetch rejection is known to have happened before the request was
140
+ // sent. Only positive evidence counts: an explicit `markNotSent`, or a
141
+ // pre-connection error code. Runtimes that expose no code (browsers, Workers)
142
+ // yield false, which is the safe answer.
143
+ export function wasNotSent(error: unknown): boolean {
144
+ for (let e: unknown = error, depth = 0; e && typeof e === 'object' && depth < 5; depth++) {
145
+ if ((e as Record<symbol, unknown>)[NOT_SENT] === true) return true;
146
+ const code = (e as { code?: unknown }).code;
147
+ if (typeof code === 'string' && PRE_SEND_CODES.has(code)) return true;
148
+ e = (e as { cause?: unknown }).cause;
82
149
  }
150
+ return false;
83
151
  }
84
152
 
85
153
  // registrar API rejected the credentials
package/src/features.ts CHANGED
@@ -102,3 +102,45 @@ const CORE_FEATURE_SET: ReadonlySet<RegistrarFeature> = new Set(CORE_FEATURES);
102
102
  export function isCoreFeature(feature: RegistrarFeature): boolean {
103
103
  return CORE_FEATURE_SET.has(feature);
104
104
  }
105
+
106
+ // How each feature method is served: whether it only reads registrar state or
107
+ // may change it, and which positional argument carries its `RequestOptions`.
108
+ //
109
+ // The intent decides what may be re-sent after a failure (see `HttpClient`). It
110
+ // is keyed by feature rather than by HTTP method because the method says
111
+ // nothing reliable: Namecheap sends every command, writes included, as a GET,
112
+ // and Porkbun sends reads as POSTs.
113
+ //
114
+ // Typed as a total record so adding a feature without classifying it fails to
115
+ // compile. `getAuthCode` is a write: several registrars regenerate the code,
116
+ // invalidating the previous one.
117
+ export const FEATURE_CALLS: Record<
118
+ RegistrarFeature,
119
+ { intent: 'read' | 'write'; optsIndex: number }
120
+ > = {
121
+ testConnection: { intent: 'read', optsIndex: 0 },
122
+ listDomains: { intent: 'read', optsIndex: 0 },
123
+ getDomain: { intent: 'read', optsIndex: 1 },
124
+ checkAvailability: { intent: 'read', optsIndex: 1 },
125
+ getPricing: { intent: 'read', optsIndex: 1 },
126
+ registerDomain: { intent: 'write', optsIndex: 2 },
127
+ renewDomain: { intent: 'write', optsIndex: 2 },
128
+ setAutoRenew: { intent: 'write', optsIndex: 2 },
129
+ transferIn: { intent: 'write', optsIndex: 2 },
130
+ updateNameservers: { intent: 'write', optsIndex: 2 },
131
+ getNameservers: { intent: 'read', optsIndex: 1 },
132
+ lockDomain: { intent: 'write', optsIndex: 1 },
133
+ unlockDomain: { intent: 'write', optsIndex: 1 },
134
+ setPrivacy: { intent: 'write', optsIndex: 2 },
135
+ getContacts: { intent: 'read', optsIndex: 1 },
136
+ updateContacts: { intent: 'write', optsIndex: 2 },
137
+ getDnsRecords: { intent: 'read', optsIndex: 1 },
138
+ setDnsRecords: { intent: 'write', optsIndex: 2 },
139
+ getAuthCode: { intent: 'write', optsIndex: 1 },
140
+ getDnssec: { intent: 'read', optsIndex: 1 },
141
+ disableDnssec: { intent: 'write', optsIndex: 1 },
142
+ getEmailForwarding: { intent: 'read', optsIndex: 1 },
143
+ setEmailForwarding: { intent: 'write', optsIndex: 2 },
144
+ getDomainForwarding: { intent: 'read', optsIndex: 1 },
145
+ setDomainForwarding: { intent: 'write', optsIndex: 2 },
146
+ };
package/src/http.ts CHANGED
@@ -6,13 +6,15 @@ import {
6
6
  ConnectionError,
7
7
  InvalidResponseError,
8
8
  NotFoundError,
9
+ OutcomeUnknownError,
9
10
  ParsingError,
10
11
  RateLimitError,
11
12
  RegistrarError,
12
13
  TimeoutError,
13
14
  toRegistrarError,
15
+ wasNotSent,
14
16
  } from './errors';
15
- import type { RegistrarClientOptions } from './types';
17
+ import type { FeatureCall, RegistrarClientOptions } from './types';
16
18
  import { backoffDelay, sleep } from './utils';
17
19
 
18
20
  // configuration for an HttpClient instance
@@ -23,8 +25,19 @@ export interface HttpClientConfig {
23
25
  headers?: Record<string, string>;
24
26
  // request/retry behavior
25
27
  options: RegistrarClientOptions;
28
+ // HTTP methods this API reserves for requests that never change state. A
29
+ // REST API sets `REST_SAFE_METHODS`, so a lookup made in the middle of a
30
+ // write (a zone list, a price check) is still retried like any read. Leave
31
+ // unset for APIs where the method means nothing: Namecheap and NameSilo send
32
+ // writes as GET. Those mark their nested reads with `asRead` instead.
33
+ safeMethods?: readonly string[];
34
+ // A provider-specific delay for HTTP 429 without a usable Retry-After.
35
+ // Otherwise the normal exponential backoff applies.
36
+ rateLimitFallbackSeconds?: number;
26
37
  }
27
38
 
39
+ export const REST_SAFE_METHODS: readonly string[] = ['GET', 'HEAD'];
40
+
28
41
  // options for a single request
29
42
  export interface RequestConfig {
30
43
  method?: string;
@@ -40,6 +53,11 @@ export interface RequestConfig {
40
53
  retries?: number;
41
54
  backoff?: number;
42
55
  signal?: AbortSignal;
56
+ fetch?: typeof globalThis.fetch;
57
+ // which feature this request serves and whether it reads or writes; arrives
58
+ // with the caller's `RequestOptions`. A request without one is treated as a
59
+ // write, the safe assumption.
60
+ call?: FeatureCall;
43
61
  }
44
62
 
45
63
  // a small, browser/edge-safe fetch wrapper with timeout, abort linking,
@@ -67,7 +85,20 @@ export class HttpClient {
67
85
  return this.withRetries(req, () => this.send(req));
68
86
  }
69
87
 
70
- // shared retry-with-backoff wrapper around a single-attempt operation
88
+ // Shared retry-with-backoff wrapper around a single-attempt operation.
89
+ //
90
+ // What may be re-sent depends on where the failure happened and on whether
91
+ // the call reads or writes:
92
+ //
93
+ // failure read write
94
+ // never sent (DNS, refused, TLS, proxy stage) retry retry
95
+ // 429 retry retry
96
+ // unknown (timeout, dropped mid-response, 5xx) retry OutcomeUnknownError
97
+ // any other response fail fail
98
+ //
99
+ // A write whose outcome is unknown may have been applied, so it is never
100
+ // re-sent: a second renewal charges twice, a second record-create leaves a
101
+ // duplicate. The caller re-reads the domain instead.
71
102
  protected async withRetries<T>(req: RequestConfig, attemptFn: () => Promise<T>): Promise<T> {
72
103
  const retries = req.retries ?? this.config.options.retries;
73
104
  const backoff = req.backoff ?? this.config.options.backoff;
@@ -79,6 +110,16 @@ export class HttpClient {
79
110
  } catch (error) {
80
111
  lastError = toRegistrarError(error);
81
112
 
113
+ if (this.intentOf(req) === 'write' && isOutcomeUnknown(lastError)) {
114
+ const unknown = new OutcomeUnknownError(
115
+ `The registrar did not confirm this change, so it may or may not have been applied. ` +
116
+ `Check the domain before trying again. (${lastError.message})`,
117
+ { feature: req.call?.feature, cause: lastError }
118
+ );
119
+ if (req.call) req.call.outcomeUnknown = unknown;
120
+ throw unknown;
121
+ }
122
+
82
123
  // out of attempts, or the error is not retryable
83
124
  if (attempt >= retries || !lastError.shouldRetry()) {
84
125
  throw lastError;
@@ -97,6 +138,15 @@ export class HttpClient {
97
138
  throw lastError ?? new RegistrarError('Request failed');
98
139
  }
99
140
 
141
+ // Whether a request only reads. A method the API reserves for reads wins;
142
+ // otherwise it is whatever the feature being served is, and a request that
143
+ // says nothing is assumed to write.
144
+ protected intentOf(req: RequestConfig): 'read' | 'write' {
145
+ const method = (req.method ?? 'GET').toUpperCase();
146
+ if (this.config.safeMethods?.includes(method)) return 'read';
147
+ return req.call?.intent ?? 'write';
148
+ }
149
+
100
150
  // build the full URL for a request, applying query parameters
101
151
  protected buildUrl(req: RequestConfig): string {
102
152
  let base: string;
@@ -146,7 +196,8 @@ export class HttpClient {
146
196
  try {
147
197
  let response: Response;
148
198
  try {
149
- response = await fetch(url, {
199
+ const doFetch = req.fetch ?? this.config.options.fetch ?? fetch;
200
+ response = await doFetch(url, {
150
201
  method: req.method ?? 'GET',
151
202
  headers,
152
203
  body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
@@ -158,7 +209,9 @@ export class HttpClient {
158
209
  if (externalSignal?.aborted) throw new AbortError('Request was aborted');
159
210
  throw new TimeoutError(`Request to '${url}' timed out after ${timeout}ms`);
160
211
  }
161
- throw new ConnectionError(`Failed to reach '${url}': ${errorMessage(error)}`);
212
+ throw new ConnectionError(`Failed to reach '${url}': ${errorMessage(error)}`, {
213
+ notSent: wasNotSent(error),
214
+ });
162
215
  }
163
216
 
164
217
  if (!response.ok) {
@@ -169,7 +222,20 @@ export class HttpClient {
169
222
  if (response.status === 204) {
170
223
  return '';
171
224
  }
172
- return await response.text();
225
+ try {
226
+ return await response.text();
227
+ } catch (error) {
228
+ // The registrar answered, then the body was cut off. The request was
229
+ // certainly sent, so for a write the outcome is unknown; a read can
230
+ // simply be retried.
231
+ if (isAbortError(error)) {
232
+ if (externalSignal?.aborted) throw new AbortError('Request was aborted');
233
+ throw new TimeoutError(`Response from '${url}' timed out after ${timeout}ms`);
234
+ }
235
+ throw new ConnectionError(
236
+ `Connection to '${url}' was lost while reading the response: ${errorMessage(error)}`
237
+ );
238
+ }
173
239
  } finally {
174
240
  clearTimeout(timeoutId);
175
241
  externalSignal?.removeEventListener('abort', onAbort);
@@ -197,8 +263,10 @@ export class HttpClient {
197
263
  case 404:
198
264
  return new NotFoundError(message);
199
265
  case 429: {
200
- const retryAfter = Number(response.headers.get('retry-after'));
201
- return new RateLimitError(message, Number.isFinite(retryAfter) ? retryAfter : undefined);
266
+ const retryAfter =
267
+ parseRetryAfter(response.headers.get('retry-after')) ??
268
+ this.config.rateLimitFallbackSeconds;
269
+ return new RateLimitError(message, retryAfter);
202
270
  }
203
271
  default: {
204
272
  if (response.status >= 500) {
@@ -214,6 +282,33 @@ export class HttpClient {
214
282
  }
215
283
  }
216
284
 
285
+ // RFC 9110 Retry-After is delay-seconds or an HTTP date. Absence must stay
286
+ // undefined: Number(null) is zero and would bypass the configured backoff.
287
+ function parseRetryAfter(value: string | null): number | undefined {
288
+ const text = value?.trim();
289
+ if (!text) return undefined;
290
+ if (/^\d+$/.test(text)) {
291
+ const seconds = Number(text);
292
+ return Number.isSafeInteger(seconds) ? seconds : undefined;
293
+ }
294
+ // Accept the HTTP-date formats from RFC 9110, not Date.parse's permissive
295
+ // numeric/calendar inputs. The obsolete asctime form has implicit UTC.
296
+ const zonedDate =
297
+ /^(?:[a-z]{3}, \d{2} [a-z]{3} \d{4}|[a-z]+, \d{2}-[a-z]{3}-\d{2}) \d{2}:\d{2}:\d{2} GMT$/i;
298
+ const asctimeDate = /^[a-z]{3} [a-z]{3} (?: \d|\d{2}) \d{2}:\d{2}:\d{2} \d{4}$/i;
299
+ if (!zonedDate.test(text) && !asctimeDate.test(text)) return undefined;
300
+ const date = Date.parse(asctimeDate.test(text) ? `${text} GMT` : text);
301
+ return Number.isFinite(date) ? Math.max(0, Math.ceil((date - Date.now()) / 1000)) : undefined;
302
+ }
303
+
304
+ // A retryable failure that leaves a write's result unknown: the request may
305
+ // have reached the registrar. A rate limit is an explicit rejection, and a
306
+ // connection error known to precede sending never reached it.
307
+ function isOutcomeUnknown(error: RegistrarError): boolean {
308
+ if (error instanceof TimeoutError || error instanceof InvalidResponseError) return true;
309
+ return error instanceof ConnectionError && !error.notSent;
310
+ }
311
+
217
312
  // detect an AbortController/fetch abort across environments
218
313
  function isAbortError(error: unknown): boolean {
219
314
  return (
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  export { RegistrarClient } from './client';
4
4
  export { listPortfolio } from './portfolio';
5
5
  export type { DomainSource, PortfolioError, PortfolioResult } from './portfolio';
6
- export { HttpClient } from './http';
6
+ export { HttpClient, REST_SAFE_METHODS } from './http';
7
7
  export type { HttpClientConfig, RequestConfig } from './http';
8
8
 
9
9
  // core registrar abstraction (the `Registrar` interface itself is exported via
@@ -15,7 +15,14 @@ export { registrars, createRegistrar } from './registrars/index';
15
15
  export type { RegistrarName } from './registrars/index';
16
16
 
17
17
  // capability model: feature constants, the core contract, and helpers
18
- export { Feature, CORE_FEATURES, EXTENDED_FEATURES, ALL_FEATURES, isCoreFeature } from './features';
18
+ export {
19
+ Feature,
20
+ CORE_FEATURES,
21
+ EXTENDED_FEATURES,
22
+ ALL_FEATURES,
23
+ FEATURE_CALLS,
24
+ isCoreFeature,
25
+ } from './features';
19
26
  export type { RegistrarFeature } from './features';
20
27
 
21
28
  // bundled providers
package/src/registrar.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { DEFAULT_OPTIONS } from './constants';
2
2
  import { ConfigurationError, NotImplementedError } from './errors';
3
3
  import { HttpClient, type HttpClientConfig } from './http';
4
- import { CORE_FEATURES, type RegistrarFeature } from './features';
4
+ import { CORE_FEATURES, FEATURE_CALLS, type RegistrarFeature } from './features';
5
5
  import type {
6
6
  ConnectionResult,
7
7
  ContactSet,
@@ -18,6 +18,7 @@ import type {
18
18
  RegistrarClientOptions,
19
19
  RegistrarCredentials,
20
20
  RegistrarEnvironment,
21
+ FeatureCall,
21
22
  RegistrarOptions,
22
23
  RequestOptions,
23
24
  TldPricing,
@@ -65,6 +66,10 @@ export abstract class BaseRegistrar implements Registrar {
65
66
  return [...CORE_FEATURES, ...this.extendedFeatures];
66
67
  }
67
68
 
69
+ // HTTP methods this provider's API reserves for reads; see
70
+ // `HttpClientConfig.safeMethods`. REST providers set `REST_SAFE_METHODS`.
71
+ static readonly safeMethods: readonly string[] = [];
72
+
68
73
  // instance mirror of the static `features`
69
74
  get features(): readonly RegistrarFeature[] {
70
75
  return (this.constructor as typeof BaseRegistrar).features;
@@ -90,7 +95,47 @@ export abstract class BaseRegistrar implements Registrar {
90
95
  this.credentials = credentials;
91
96
  this.environment = environment;
92
97
  this.options = { ...DEFAULT_OPTIONS, ...clientOptions };
93
- this.http = new HttpClient({ ...httpConfig, options: this.options });
98
+ this.http = new HttpClient({
99
+ safeMethods: (this.constructor as typeof BaseRegistrar).safeMethods,
100
+ ...httpConfig,
101
+ options: this.options,
102
+ });
103
+ this.threadFeatureCalls();
104
+ }
105
+
106
+ // Wrap each feature method so its `RequestOptions` carry a `FeatureCall`:
107
+ // the read/write intent the HTTP layer needs to decide what may be re-sent,
108
+ // and a slot where it reports a write whose outcome is unknown. Providers
109
+ // already forward their options into every request, so nothing else changes.
110
+ //
111
+ // A feature calling another feature (a write re-reading the domain, say)
112
+ // goes through that feature's own wrapper, so the inner read is still a read.
113
+ // Providers fold most failures into an `OperationResult`; the unknown outcome
114
+ // is copied onto that result so it is not lost along with the error.
115
+ private threadFeatureCalls(): void {
116
+ const self = this as unknown as Record<string, unknown>;
117
+ for (const [feature, { intent, optsIndex }] of Object.entries(FEATURE_CALLS)) {
118
+ const original = self[feature];
119
+ if (typeof original !== 'function') continue;
120
+ // Not `async`: a provider that validates its arguments by throwing
121
+ // synchronously keeps doing so.
122
+ self[feature] = (...args: unknown[]): unknown => {
123
+ const call: FeatureCall = { intent, feature };
124
+ const next = [...args];
125
+ while (next.length <= optsIndex) next.push(undefined);
126
+ next[optsIndex] = { ...(next[optsIndex] as RequestOptions | undefined), call };
127
+ const result: unknown = (original as (...a: unknown[]) => unknown).apply(this, next);
128
+ if (!(result instanceof Promise)) return result;
129
+ return result.then((value: unknown) =>
130
+ call.outcomeUnknown &&
131
+ value !== null &&
132
+ typeof value === 'object' &&
133
+ (value as OperationResult).success === false
134
+ ? { ...value, outcome: 'unknown' }
135
+ : value
136
+ );
137
+ };
138
+ }
94
139
  }
95
140
 
96
141
  testConnection(_opts?: RequestOptions): Promise<ConnectionResult> {
@@ -15,6 +15,7 @@ import type {
15
15
  RequestOptions,
16
16
  TldPricing,
17
17
  } from '../types';
18
+ import { REST_SAFE_METHODS } from '../http';
18
19
  import { createDomain, filterDomains, normalizeDomain, settableForwards } from '../utils';
19
20
  import { NotImplementedError, toRegistrarError } from '../errors';
20
21
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
@@ -173,6 +174,8 @@ interface CfDnsRecord {
173
174
  */
174
175
  export class CloudflareRegistrar extends BaseRegistrar {
175
176
  readonly name = 'cloudflare';
177
+ // a REST API: GET and HEAD never change state
178
+ static override readonly safeMethods = REST_SAFE_METHODS;
176
179
 
177
180
  static readonly displayName = 'Cloudflare';
178
181
  static readonly website = 'cloudflare.com';
@@ -17,6 +17,7 @@ import type {
17
17
  TldPricing,
18
18
  TransferDomainInput,
19
19
  } from '../types';
20
+ import { REST_SAFE_METHODS } from '../http';
20
21
  import { createDomain, filterDomains, requireConsent, settableForwards } from '../utils';
21
22
  import { NotFoundError, NotImplementedError, toRegistrarError } from '../errors';
22
23
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
@@ -178,6 +179,8 @@ const SUPPORTED_DNS_TYPES = new Set(['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'S
178
179
  */
179
180
  export class DynadotRegistrar extends BaseRegistrar {
180
181
  readonly name = 'dynadot';
182
+ // a REST API: GET and HEAD never change state
183
+ static override readonly safeMethods = REST_SAFE_METHODS;
181
184
 
182
185
  static readonly displayName = 'Dynadot';
183
186
  static readonly website = 'dynadot.com';
@@ -213,6 +216,9 @@ export class DynadotRegistrar extends BaseRegistrar {
213
216
  sandbox: 'https://api-sandbox.dynadot.com',
214
217
  }),
215
218
  headers: { Authorization: `Bearer ${credentials.apiKey}` },
219
+ // HTTP 429 can omit Retry-After. Avoid exhausting all attempts inside
220
+ // Dynadot's regular-tier minute window in that case.
221
+ rateLimitFallbackSeconds: 60,
216
222
  },
217
223
  options
218
224
  );
@@ -19,6 +19,7 @@ import type {
19
19
  TldPricing,
20
20
  TransferDomainInput,
21
21
  } from '../types';
22
+ import { REST_SAFE_METHODS } from '../http';
22
23
  import { createDomain, filterDomains, settableForwards } from '../utils';
23
24
  import { toRegistrarError } from '../errors';
24
25
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
@@ -159,6 +160,8 @@ const GANDI_TYPE_TO_FORWARD: Record<string, DomainForwardType> = {
159
160
  */
160
161
  export class GandiRegistrar extends BaseRegistrar {
161
162
  readonly name = 'gandi';
163
+ // a REST API: GET and HEAD never change state
164
+ static override readonly safeMethods = REST_SAFE_METHODS;
162
165
 
163
166
  static readonly displayName = 'Gandi.net';
164
167
  static readonly website = 'gandi.net';
@@ -17,6 +17,7 @@ import type {
17
17
  TldPricing,
18
18
  TransferDomainInput,
19
19
  } from '../types';
20
+ import { REST_SAFE_METHODS } from '../http';
20
21
  import { createDomain, filterDomains, settableForwards } from '../utils';
21
22
  import {
22
23
  ConfigurationError,
@@ -267,6 +268,8 @@ interface GdV3Operation {
267
268
  */
268
269
  export class GoDaddyRegistrar extends BaseRegistrar {
269
270
  readonly name = 'godaddy';
271
+ // a REST API: GET and HEAD never change state
272
+ static override readonly safeMethods = REST_SAFE_METHODS;
270
273
 
271
274
  static readonly displayName = 'GoDaddy';
272
275
  static readonly website = 'godaddy.com';
@@ -13,12 +13,13 @@ import type {
13
13
  RequestOptions,
14
14
  TldPricing,
15
15
  } from '../types';
16
- import { createDomain, filterDomains } from '../utils';
16
+ import { asRead, createDomain, filterDomains } from '../utils';
17
17
  import { AuthenticationError, NotImplementedError, toRegistrarError } from '../errors';
18
18
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
19
19
  import { Feature, type RegistrarFeature } from '../features';
20
20
  import type { RegistrarCredentials } from '../types';
21
21
  import type { RequestConfig } from '../http';
22
+ import { REST_SAFE_METHODS } from '../http';
22
23
 
23
24
  const TOKEN_URL = 'https://api.namebright.com/auth/token';
24
25
 
@@ -196,6 +197,8 @@ interface NbAvailability {
196
197
  */
197
198
  export class NameBrightRegistrar extends BaseRegistrar {
198
199
  readonly name = 'namebright';
200
+ // a REST API: GET and HEAD never change state
201
+ static override readonly safeMethods = REST_SAFE_METHODS;
199
202
 
200
203
  static readonly displayName = 'NameBright';
201
204
  static readonly website = 'namebright.com';
@@ -243,7 +246,8 @@ export class NameBrightRegistrar extends BaseRegistrar {
243
246
  client_id: this.credentials.clientId,
244
247
  client_secret: this.credentials.clientSecret,
245
248
  },
246
- ...opts,
249
+ // issuing a token changes nothing on the account, whatever it precedes
250
+ ...asRead(opts),
247
251
  });
248
252
  if (!res.access_token) {
249
253
  throw new AuthenticationError('NameBright: token endpoint returned no access_token');
@@ -18,6 +18,7 @@ import type {
18
18
  TransferDomainInput,
19
19
  } from '../types';
20
20
  import {
21
+ asRead,
21
22
  createDomain,
22
23
  filterDomains,
23
24
  normalizeDomain,
@@ -59,6 +60,7 @@ interface NcCheckEl {
59
60
  '@_Available'?: string;
60
61
  '@_IsPremiumName'?: string;
61
62
  '@_PremiumRegistrationPrice'?: string;
63
+ '@_PremiumRenewalPrice'?: string;
62
64
  }
63
65
 
64
66
  // a contact group in namecheap.domains.getContacts (children are text elements)
@@ -366,9 +368,10 @@ export class NamecheapRegistrar extends BaseRegistrar {
366
368
 
367
369
  /**
368
370
  * Availability via domains.check (batched at 50 per request, Namecheap's cap).
369
- * The check response only carries a price for *premium* names
370
- * (`PremiumRegistrationPrice`); regular registration pricing comes from
371
- * `getPricing`, so `price` is set only for premium results.
371
+ * The check response only carries prices for *premium* names
372
+ * (`PremiumRegistrationPrice`, `PremiumRenewalPrice`); regular pricing comes
373
+ * from `getPricing`, so `price` and `renewalPrice` are set only for premium
374
+ * results.
372
375
  */
373
376
  override async checkAvailability(
374
377
  domainNames: string[],
@@ -384,12 +387,16 @@ export class NamecheapRegistrar extends BaseRegistrar {
384
387
  );
385
388
  for (const el of ensureArray(cr.DomainCheckResult)) {
386
389
  const premium = el['@_IsPremiumName'] === 'true';
387
- const premiumPrice = premium ? Number(el['@_PremiumRegistrationPrice']) : NaN;
390
+ const premiumPrice = (attr: string | undefined) => {
391
+ const n = premium ? Number(attr) : NaN;
392
+ return Number.isFinite(n) && n > 0 ? n : undefined;
393
+ };
388
394
  results.push({
389
395
  domainName: el['@_Domain'] ?? '',
390
396
  available: el['@_Available'] === 'true',
391
397
  premium,
392
- price: Number.isFinite(premiumPrice) && premiumPrice > 0 ? premiumPrice : undefined,
398
+ price: premiumPrice(el['@_PremiumRegistrationPrice']),
399
+ renewalPrice: premiumPrice(el['@_PremiumRenewalPrice']),
393
400
  });
394
401
  }
395
402
  }
@@ -580,7 +587,11 @@ export class NamecheapRegistrar extends BaseRegistrar {
580
587
  enabled: boolean,
581
588
  opts?: RequestOptions
582
589
  ): Promise<OperationResult> {
583
- const cr = await this.command('namecheap.domains.getInfo', { DomainName: domainName }, opts);
590
+ const cr = await this.command(
591
+ 'namecheap.domains.getInfo',
592
+ { DomainName: domainName },
593
+ asRead(opts)
594
+ );
584
595
  const id = cr.DomainGetInfoResult?.Whoisguard?.ID;
585
596
  if (id == null || String(id) === '0') {
586
597
  throw new Error(`${this.name}: ${domainName} has no WhoisGuard subscription to toggle`);