quarkusio/quarkus · error · RuntimeException

Rate limit exceeded: too many ACME challenge requests. Wait

Error message

Rate limit exceeded: too many ACME challenge requests. Wait 60 seconds and try again.

What it means

AcmeClient implements a client-side rate limiter: checkRateLimit increments a per-minute counter and, once more than MAX_REQUESTS_PER_MINUTE ACME challenge requests are issued within a rolling minute, it throws this RuntimeException. It protects the local ACME challenge/management endpoints (and the Let's Encrypt workflow) from request floods, e.g. retry loops that poll or re-upload challenges too aggressively.

Source

Thrown at extensions/tls-registry/cli/src/main/java/io/quarkus/tls/cli/letsencrypt/AcmeClient.java:102

    }

    private void checkRateLimit(String operation) {
        long now = System.currentTimeMillis();
        long windowStart = windowStartTime.get();

        if (now - windowStart >= RATE_LIMIT_WINDOW_MS) {
            if (windowStartTime.compareAndSet(windowStart, now)) {
                requestCount.set(0);
            }
        }

        int currentCount = requestCount.incrementAndGet();
        if (currentCount > MAX_REQUESTS_PER_MINUTE) {
            AUDIT.warn("Rate limit exceeded - operation: " + operation + ", requests: " + currentCount + "/"
                    + MAX_REQUESTS_PER_MINUTE + ", endpoint: " + challengeUrl);
            LOGGER.warn("⚠️  Rate limit exceeded: " + currentCount + " requests in the last minute");
            LOGGER.warn("⚠️  Maximum allowed: " + MAX_REQUESTS_PER_MINUTE + " requests per minute");
            throw new RuntimeException(
                    "Rate limit exceeded: too many ACME challenge requests. Wait 60 seconds and try again.");
        }
    }

    public boolean checkReadiness() {

        // Check status
        LOGGER.infof("\uD83D\uDD35 Checking management challenge endpoint status using %s", challengeUrl);
        HttpRequest<Buffer> request = managementClient.getAbs(challengeUrl);
        addKeyAndUser(request);
        try {
            HttpResponse<Buffer> response = await(request.send());
            int status = response.statusCode();
            switch (status) {
                case 200, 204 -> {
                    return true;
                }
                case 404 -> {

View on GitHub (pinned to e1c734241f)

Solutions

  1. Wait 60 seconds for the request counter window to reset, then retry the operation
  2. Add exponential backoff to your retry logic around AcmeClient calls instead of fixed short delays
  3. Reduce polling frequency: check certificateChainAndKeyAreReady readiness with longer intervals between checks
  4. Deduplicate concurrent certificate requests (a single scheduled renewal job per account instead of per-instance loops)

Example fix

// before
while (!ready) { acmeClient.certificateChainAndKeyAreReady(...); Thread.sleep(200); }

// after
long backoff = 1000;
while (!ready) {
    try {
        ready = acmeClient.certificateChainAndKeyAreReady(...);
    } catch (RuntimeException e) {
        Thread.sleep(60_000); // honor rate-limit window
        continue;
    }
    Thread.sleep(backoff);
    backoff = Math.min(backoff * 2, 30_000);
}
Defensive patterns

Strategy: retry

Validate before calling

import java.time.Instant;

private static final int MAX_REQUESTS_PER_MINUTE = 30; // match AcmeClient limit
private final java.util.ArrayDeque<Instant> recentCalls = new java.util.ArrayDeque<>();

synchronized boolean mayCallNow() {
    Instant now = Instant.now();
    while (!recentCalls.isEmpty() && recentCalls.peek().isBefore(now.minusSeconds(60))) {
        recentCalls.poll();
    }
    if (recentCalls.size() >= MAX_REQUESTS_PER_MINUTE) return false;
    recentCalls.add(now);
    return true;
}
// call mayCallNow() before each AcmeClient operation

Try / catch

try {
    acmeClient.proveIdentifierControl(domain, account, ...);
} catch (RuntimeException e) {
    if (e.getMessage().contains("Rate limit exceeded")) {
        Thread.sleep(61_000); // let the 60s window reset
        // retry once with backoff, do not loop tightly
    } else {
        throw e;
    }
}

Prevention

When it happens

Trigger: Calling proveIdentifierControl, cleanupAfterChallenge, or certificateChainAndKeyAreReady more than MAX_REQUESTS_PER_MINUTE times within 60 seconds — typically an application-level retry loop that repeatedly uploads the HTTP-01 challenge or polls readiness without backoff, or several certificate requests running concurrently through the same client.

Common situations: Retry frameworks (e.g. @Retryable, resilience4j) wrapping AcmeClient calls with short intervals; automated tests re-running the ACME flow in quick succession; a misconfigured scheduler renewing certificates in a tight loop; multiple application instances sharing one ACME account hitting the same challenge endpoints.

Related errors


AI-assisted analysis of quarkusio/quarkus@e1c734241f (2026-09-05). Data as JSON: /api/errors/55cc710b5f272f3b. Report an issue: GitHub.