From 0a64d46d4c1b7b7330cd4421dffad1ab244d0eb2 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Tue, 30 Jun 2026 18:00:58 +0530 Subject: [PATCH] feat(ssl): add inter-site jitter and clear rate-limit messaging to ssl-renew --- src/Site_Command.php | 8 +++++++ src/helper/Site_Letsencrypt.php | 42 ++++++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/Site_Command.php b/src/Site_Command.php index 9967fdcf..4144532f 100644 --- a/src/Site_Command.php +++ b/src/Site_Command.php @@ -104,6 +104,8 @@ public function __invoke( $args, $assoc_args ) { } elseif ( in_array( reset( $args ), [ 'ssl-renew' ], true ) && array_key_exists( 'all', $assoc_args ) ) { $sites = Site::all(); unset( $assoc_args['all'] ); + // Spread per-site dispatches over time to avoid bursting against Let's Encrypt rate limits. + $dispatched = false; foreach ( $sites as $site ) { $type = $site->site_type; $args = [ 'site', 'ssl-renew', $site->site_url ]; @@ -125,11 +127,17 @@ public function __invoke( $args, $assoc_args ) { continue; } + // Jitter between sites only (not before the first / after the last) to smooth burst load on the LE API. + if ( $dispatched ) { + sleep( random_int( 1, 5 ) ); + } + $command = EE::get_root_command(); $leaf_command = CommandFactory::create( 'site', $callback, $command ); $command->add_subcommand( 'site', $leaf_command ); EE::run_command( $args, $assoc_args ); + $dispatched = true; } die; } else { diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 0669eaf1..76bcbf47 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -16,6 +16,7 @@ use AcmePhp\Core\Challenge\WaitingValidator; use AcmePhp\Core\Exception\Protocol\ChallengeNotSupportedException; use AcmePhp\Core\Exception\Protocol\CertificateRevocationException; +use AcmePhp\Core\Exception\Server\RateLimitedServerException; use AcmePhp\Core\Protocol\AuthorizationChallenge; use AcmePhp\Core\Protocol\ResourcesDirectory; use AcmePhp\Core\Protocol\RevocationReason; @@ -208,7 +209,12 @@ public function authorize( Array $domains, $wildcard = false, $preferred_challen try { $order = $this->client->requestOrder( $domains ); } catch ( \Exception $e ) { - \EE::warning( 'It seems you\'re in local environment or using non-public domain, please check logs. Skipping letsencrypt.' ); + // A rate-limit is a distinct failure from a non-public domain; emit a clear, actionable message for it. + if ( $this->is_rate_limit_exception( $e ) ) { + \EE::warning( 'Let\'s Encrypt rate limit hit for: ' . implode( ', ', $domains ) . '. Please wait before retrying. Ref: https://letsencrypt.org/docs/rate-limits/' ); + } else { + \EE::warning( 'It seems you\'re in local environment or using non-public domain, please check logs. Skipping letsencrypt.' ); + } \EE::log( 'You can fix the issue and re-run: ee site ssl-verify ' . $domains[0] ); return false; @@ -568,6 +574,26 @@ public function isRenewalNecessary( $domain ) { return true; } + /** + * Whether the given exception represents a Let's Encrypt rate-limit response. + * + * Matches the acmephp RateLimitedServerException as well as the 'rateLimited' ACME error + * type / HTTP 429 surfaced in the message, so callers can show rate-limit-specific guidance. + * + * @param \Throwable $e + * + * @return bool + */ + private function is_rate_limit_exception( $e ) { + if ( $e instanceof RateLimitedServerException ) { + return true; + } + + $message = strtolower( $e->getMessage() ); + + return ( false !== strpos( $message, 'ratelimited' ) || false !== strpos( $message, 'too many' ) ); + } + /** * Renew a given domain certificate. * @@ -644,7 +670,12 @@ private function executeRenewal( $domain, array $alternativeNames, $force = fals \EE::warning( 'A critical error occured during certificate renewal' ); \EE::debug( print_r( $e, true ) ); - \EE::warning( 'Challenge Authorization failed. Check logs and check if your domain is pointed correctly to this server.' ); + // A rate-limit is not a misconfigured-domain failure; point the user to the LE rate-limit docs instead. + if ( $this->is_rate_limit_exception( $e ) ) { + \EE::warning( 'Let\'s Encrypt rate limit hit for: ' . $domain . '. Please wait before retrying. Ref: https://letsencrypt.org/docs/rate-limits/' ); + } else { + \EE::warning( 'Challenge Authorization failed. Check logs and check if your domain is pointed correctly to this server.' ); + } \EE::log( 'You can fix the issue and re-run: ee site ssl-verify ' . $domains[0] ); return false; @@ -652,7 +683,12 @@ private function executeRenewal( $domain, array $alternativeNames, $force = fals \EE::warning( 'A critical error occured during certificate renewal' ); \EE::debug( print_r( $e, true ) ); - \EE::warning( 'Challenge Authorization failed. Check logs and check if your domain is pointed correctly to this server.' ); + // A rate-limit is not a misconfigured-domain failure; point the user to the LE rate-limit docs instead. + if ( $this->is_rate_limit_exception( $e ) ) { + \EE::warning( 'Let\'s Encrypt rate limit hit for: ' . $domain . '. Please wait before retrying. Ref: https://letsencrypt.org/docs/rate-limits/' ); + } else { + \EE::warning( 'Challenge Authorization failed. Check logs and check if your domain is pointed correctly to this server.' ); + } \EE::log( 'You can fix the issue and re-run: ee site ssl-verify ' . $domains[0] ); return false;