feat(smartacme): Implement exponential backoff retry logic and graceful shutdown handling in SmartAcme; update acme-client dependency to v5.4.0

This commit is contained in:
2025-04-27 13:21:41 +00:00
parent 82bfc20a6d
commit 56a440660b
5 changed files with 109 additions and 95 deletions

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartacme',
version: '5.0.1',
version: '5.1.0',
description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
}

View File

@ -14,6 +14,19 @@ export interface ISmartAcmeOptions {
setChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
removeChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
environment: 'production' | 'integration';
/**
* Optional retry/backoff configuration for transient failures
*/
retryOptions?: {
/** number of retry attempts */
retries?: number;
/** backoff multiplier */
factor?: number;
/** initial delay in milliseconds */
minTimeoutMs?: number;
/** maximum delay cap in milliseconds */
maxTimeoutMs?: number;
};
}
/**
@ -30,7 +43,7 @@ export class SmartAcme {
private options: ISmartAcmeOptions;
// the acme client
private client: any;
private client: plugins.acme.Client;
private smartdns = new plugins.smartdnsClient.Smartdns({});
public logger: plugins.smartlog.Smartlog;
@ -44,10 +57,23 @@ export class SmartAcme {
// certmanager
private certmanager: SmartacmeCertManager;
private certmatcher: SmartacmeCertMatcher;
// retry/backoff configuration (resolved with defaults)
private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number };
// track pending DNS challenges for graceful shutdown
private pendingChallenges: plugins.tsclass.network.IDnsChallenge[] = [];
constructor(optionsArg: ISmartAcmeOptions) {
this.options = optionsArg;
this.logger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo);
// enable console output for structured logging
this.logger.enableConsole();
// initialize retry/backoff options
this.retryOptions = {
retries: optionsArg.retryOptions?.retries ?? 3,
factor: optionsArg.retryOptions?.factor ?? 2,
minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000,
maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 30000,
};
}
/**
@ -88,11 +114,55 @@ export class SmartAcme {
termsOfServiceAgreed: true,
contact: [`mailto:${this.options.accountEmail}`],
});
// Setup graceful shutdown handlers
process.on('SIGINT', () => this.handleSignal('SIGINT'));
process.on('SIGTERM', () => this.handleSignal('SIGTERM'));
}
public async stop() {
await this.certmanager.smartdataDb.close();
}
public async stop() {
await this.certmanager.smartdataDb.close();
}
/** Retry helper with exponential backoff */
private async retry<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> {
let attempt = 0;
let delay = this.retryOptions.minTimeoutMs;
while (true) {
try {
return await operation();
} catch (err) {
attempt++;
if (attempt > this.retryOptions.retries) {
await this.logger.log('error', `Operation ${operationName} failed after ${attempt} attempts`, err);
throw err;
}
await this.logger.log('warn', `Operation ${operationName} failed on attempt ${attempt}, retrying in ${delay}ms`, err);
await plugins.smartdelay.delayFor(delay);
delay = Math.min(delay * this.retryOptions.factor, this.retryOptions.maxTimeoutMs);
}
}
}
/** Clean up pending challenges and shut down */
private async handleShutdown(): Promise<void> {
for (const challenge of [...this.pendingChallenges]) {
try {
await this.removeChallenge(challenge);
await this.logger.log('info', 'Removed pending challenge during shutdown', challenge);
} catch (err) {
await this.logger.log('error', 'Failed to remove pending challenge during shutdown', err);
}
}
this.pendingChallenges = [];
await this.stop();
}
/** Handle process signals for graceful shutdown */
private handleSignal(sig: string): void {
this.logger.log('info', `Received signal ${sig}, shutting down gracefully`);
this.handleShutdown()
.then(() => process.exit(0))
.catch((err) => {
this.logger.log('error', 'Error during shutdown', err).then(() => process.exit(1));
});
}
/**
* gets a certificate
@ -128,54 +198,54 @@ export class SmartAcme {
// lets make sure others get the same interest
const currentDomainInterst = await this.certmanager.interestMap.addInterest(certDomainName);
/* Place new order */
const order = await this.client.createOrder({
/* Place new order with retry */
const order = await this.retry(() => this.client.createOrder({
identifiers: [
{ type: 'dns', value: certDomainName },
{ type: 'dns', value: `*.${certDomainName}` },
],
});
}), 'createOrder');
/* Get authorizations and select challenges */
const authorizations = await this.client.getAuthorizations(order);
const authorizations = await this.retry(() => this.client.getAuthorizations(order), 'getAuthorizations');
for (const authz of authorizations) {
console.log(authz);
await this.logger.log('debug', 'Authorization received', authz);
const fullHostName: string = `_acme-challenge.${authz.identifier.value}`;
const dnsChallenge: string = authz.challenges.find((challengeArg) => {
const dnsChallenge = authz.challenges.find((challengeArg) => {
return challengeArg.type === 'dns-01';
});
// process.exit(1);
const keyAuthorization: string = await this.client.getChallengeKeyAuthorization(dnsChallenge);
// prepare DNS challenge record and track for cleanup
const challengeRecord: plugins.tsclass.network.IDnsChallenge = { hostName: fullHostName, challenge: keyAuthorization };
this.pendingChallenges.push(challengeRecord);
try {
/* Satisfy challenge */
await this.setChallenge({
hostName: fullHostName,
challenge: keyAuthorization,
});
await this.retry(() => this.setChallenge(challengeRecord), 'setChallenge');
await plugins.smartdelay.delayFor(30000);
await this.smartdns.checkUntilAvailable(fullHostName, 'TXT', keyAuthorization, 100, 5000);
console.log('Cool down an extra 60 second for region availability');
await this.retry(() => this.smartdns.checkUntilAvailable(fullHostName, 'TXT', keyAuthorization, 100, 5000), 'dnsCheckUntilAvailable');
await this.logger.log('info', 'Cooling down extra 60 seconds for DNS regional propagation');
await plugins.smartdelay.delayFor(60000);
/* Verify that challenge is satisfied */
await this.client.verifyChallenge(authz, dnsChallenge);
await this.retry(() => this.client.verifyChallenge(authz, dnsChallenge), 'verifyChallenge');
/* Notify ACME provider that challenge is satisfied */
await this.client.completeChallenge(dnsChallenge);
await this.retry(() => this.client.completeChallenge(dnsChallenge), 'completeChallenge');
/* Wait for ACME provider to respond with valid status */
await this.client.waitForValidStatus(dnsChallenge);
await this.retry(() => this.client.waitForValidStatus(dnsChallenge), 'waitForValidStatus');
} finally {
/* Clean up challenge response */
try {
await this.removeChallenge({
hostName: fullHostName,
challenge: keyAuthorization,
});
} catch (e) {
console.log(e);
await this.retry(() => this.removeChallenge(challengeRecord), 'removeChallenge');
} catch (err) {
await this.logger.log('error', 'Error removing DNS challenge', err);
} finally {
// remove from pending list
this.pendingChallenges = this.pendingChallenges.filter(c => c !== challengeRecord);
}
}
}
@ -186,8 +256,8 @@ export class SmartAcme {
altNames: [certDomainName],
});
await this.client.finalizeOrder(order, csr);
const cert = await this.client.getCertificate(order);
await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder');
const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate');
/* Done */