import { PlatformError, type IErrorContext } from '../errors/base.errors.js'; import { DCR_ACME_PERMANENT_FAILURE, ErrorCategory, ErrorRecoverability, ErrorSeverity, } from '../errors/error.codes.js'; import { DomainOwnershipError } from '../dns/domain-ownership.js'; /** * ACME failure classification. * * dcrouter has two independent ACME retry budgets, and neither used to look at * *why* a failure happened: * * 1. `SmartAcmeLifecycle` — SmartAcme provider startup. 5 s→1 h exponential * backoff with jitter, hard cap of 20 attempts, then permanent give-up. * Only a SmartProxy rebuild or a process restart re-arms it. * 2. `CertProvisionScheduler` — per-domain certificate provisioning. * `min(failures², 24 h)` backoff, **no cap**, re-armed by time forever. * This is the budget that reached 31–45 failures on the broken domains. * * Retrying is correct for rate limits, DNS propagation and transport faults. It * is never correct for a configuration cause: a hostname with no managed domain * cannot acquire one by waiting, so every one of those attempts was a silent * no-op that also kept the real reason out of the operator's view. Permanent * causes must therefore skip the budget entirely and surface attributably. * * Unclassified causes stay transient on purpose. Guessing "permanent" would * strand recoverable domains, so the default preserves existing retry behaviour; * only causes we can positively recognise are treated as terminal. */ export type TAcmeFailureReason = /** Ownership of the hostname could not be proven (no DomainDoc / no provider zone / not delegation-verified). */ | 'domain-ownership-unverified' /** The DNS-01 dispatcher found no managed zone able to hold the challenge record. */ | 'no-managed-dns-zone' /** No challenge handler / provider is wired for this domain at all. */ | 'no-challenge-handler' /** The ACME account itself is misconfigured (email, terms, key, directory URL). */ | 'acme-account-configuration' /** CAA forbids our issuer — only the domain holder can change this. */ | 'caa-forbids-issuance' /** ACME server rate limit — retrying is exactly right. */ | 'rate-limited' /** Challenge not yet visible, propagation delay, transport fault. */ | 'transient' /** Nothing recognised. Treated as transient so recoverable causes keep retrying. */ | 'unclassified'; export interface IAcmeFailureClassification { reason: TAcmeFailureReason; /** True when no retry can resolve the cause, so the retry budget must not be consumed. */ permanent: boolean; message: string; } interface IReasonPattern { reason: TAcmeFailureReason; permanent: boolean; patterns: RegExp[]; } /** * Ordered most-specific-first. Each pattern must only match text that uniquely * identifies the cause — a false "permanent" verdict silently stops legitimate * retries, which is a worse failure than an extra retry. */ const reasonPatterns: IReasonPattern[] = [ { // Thrown by DnsManager.buildAcmeConvenientDnsProvider() when no DomainDoc covers the FQDN. reason: 'no-managed-dns-zone', permanent: true, patterns: [ /no managed domain found for/i, /add the domain in domains before issuing certificates/i, ], }, { reason: 'no-challenge-handler', permanent: true, patterns: [ /no (?:challenge )?handler (?:found |available )?for/i, /no dns-01 (?:handler|provider)/i, /domain is not supported by any challenge handler/i, ], }, { reason: 'caa-forbids-issuance', permanent: true, patterns: [ /caa record(?:s)? (?:for [^\s]+ )?(?:prevent|forbid|do not allow)/i, /urn:ietf:params:acme:error:caa/i, ], }, { reason: 'acme-account-configuration', permanent: true, patterns: [ /urn:ietf:params:acme:error:invalidemail/i, /urn:ietf:params:acme:error:accountdoesnotexist/i, /urn:ietf:params:acme:error:unsupportedcontact/i, /must agree to (?:the )?terms of service/i, /accountemail is required/i, ], }, { reason: 'rate-limited', permanent: false, patterns: [ /urn:ietf:params:acme:error:ratelimited/i, /too many (?:certificates|requests|failed authorizations)/i, /rate ?limit/i, ], }, ]; const extractMessage = (errorArg: unknown): string => { if (errorArg instanceof Error) return errorArg.message; if (typeof errorArg === 'string') return errorArg; if (errorArg && typeof errorArg === 'object' && 'message' in errorArg) { return String((errorArg as { message: unknown }).message); } return String(errorArg); }; /** * Classify an ACME/provisioning failure into a retryable or terminal cause. * Structured errors win over text matching: a `DomainOwnershipError` and any * NON_RECOVERABLE CONFIGURATION `PlatformError` are permanent by declaration. */ export const classifyAcmeFailure = (errorArg: unknown): IAcmeFailureClassification => { const message = extractMessage(errorArg); if (errorArg instanceof DomainOwnershipError) { return { reason: 'domain-ownership-unverified', permanent: true, message }; } if ( errorArg instanceof PlatformError && errorArg.category === ErrorCategory.CONFIGURATION && errorArg.recoverability === ErrorRecoverability.NON_RECOVERABLE ) { return { reason: 'acme-account-configuration', permanent: true, message }; } for (const candidate of reasonPatterns) { if (candidate.patterns.some((pattern) => pattern.test(message))) { return { reason: candidate.reason, permanent: candidate.permanent, message }; } } return { reason: 'unclassified', permanent: false, message }; }; /** * Terminal ACME failure. HIGH severity so the automatic PlatformError log lands * at `error` rather than being lost in provisioning warn noise, and * NON_RECOVERABLE so `isRetryable()` and every downstream retry layer agree that * this must not be retried. */ export class AcmePermanentFailureError extends PlatformError { public readonly classification: IAcmeFailureClassification; constructor( classification: IAcmeFailureClassification, operation: string, component: string, context: IErrorContext = {}, ) { super( `${operation} failed permanently (${classification.reason}): ${classification.message}`, DCR_ACME_PERMANENT_FAILURE, ErrorSeverity.HIGH, ErrorCategory.CONFIGURATION, ErrorRecoverability.NON_RECOVERABLE, { component, operation, userMessage: `${operation} cannot succeed until its configuration is fixed (${classification.reason}): ${classification.message}`, ...context, data: { acmeFailureReason: classification.reason, ...context.data, }, }, ); this.classification = classification; } protected createWithContext(context: IErrorContext): PlatformError { return new AcmePermanentFailureError( this.classification, this.context.operation || 'operation', this.context.component || 'acme', context, ); } }