elastic/elasticsearch · error · IllegalArgumentException

maxAttempts must be >= 1 but was [{}]

Error message

maxAttempts must be >= 1 but was [{}]

What it means

HttpUtils.readHttpBytesWithRetry validates its maxAttempts argument up front. The retry loop iterates attempt = 1..maxAttempts, so maxAttempts <= 0 would mean zero attempts and silent fall-through; the guard rejects non-positive values with the actual invalid value embedded.

Source

Thrown at build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/util/HttpUtils.java:33

public final class HttpUtils {

    private static final int HTTP_READ_MAX_ATTEMPTS = 3;
    private static final long HTTP_READ_RETRY_BACKOFF_MILLIS = 1000L;

    private HttpUtils() {}

    @FunctionalInterface
    public interface Sleeper {
        void sleep(long millis) throws InterruptedException;
    }

    public static byte[] readHttpBytesWithRetry(String url) throws IOException {
        return readHttpBytesWithRetry(url, HTTP_READ_MAX_ATTEMPTS, HTTP_READ_RETRY_BACKOFF_MILLIS, Thread::sleep);
    }

    public static byte[] readHttpBytesWithRetry(String url, int maxAttempts, long baseBackoffMillis, Sleeper sleeper) throws IOException {
        if (maxAttempts <= 0) {
            throw new IllegalArgumentException("maxAttempts must be >= 1 but was [" + maxAttempts + "]");
        }
        if (baseBackoffMillis < 0) {
            throw new IllegalArgumentException("baseBackoffMillis must be >= 0 but was [" + baseBackoffMillis + "]");
        }

        IOException lastException = null;
        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            if (attempt > 1 && baseBackoffMillis > 0) {
                long backoff = baseBackoffMillis * (attempt - 1);
                try {
                    sleeper.sleep(backoff);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    throw new IOException("Interrupted while retrying download from: " + url, e);
                }
            }

            try (InputStream in = URI.create(url).toURL().openStream()) {

View on GitHub (pinned to db6a809a66)

Solutions

  1. Pass maxAttempts >= 1; use the built-in default HTTP_READ_MAX_ATTEMPTS when unsure.
  2. Clamp configured values: Math.max(1, configuredAttempts) before calling.
  3. If the 0 came from a missing config, fix the config resolution default rather than masking it.

Example fix

// before
HttpUtils.readHttpBytesWithRetry(url, configuredAttempts, backoff, sleeper); // configuredAttempts=0
// after
HttpUtils.readHttpBytesWithRetry(url, Math.max(1, configuredAttempts), backoff, sleeper);
Defensive patterns

Strategy: validation

Validate before calling

int safeAttempts = Math.max(1, configuredAttempts);
HttpUtils.readHttpBytesWithRetry(url, safeAttempts, backoff, sleeper);

Try / catch

try { HttpUtils.readHttpBytesWithRetry(url, attempts, backoff, sleeper); } catch (IllegalArgumentException e) { /* log misconfig and fall back to defaults */ HttpUtils.readHttpBytesWithRetry(url); }

Prevention

When it happens

Trigger: Calling readHttpBytesWithRetry(url, maxAttempts, ...) with maxAttempts = 0 or negative, typically via a computed/passed retry count from configuration that resolved to zero.

Common situations: A Gradle build property or env var for HTTP retry count defaulting to 0 when unset; passing a literal 0 during testing; arithmetic that underflows to <= 0.

Related errors


AI-assisted analysis of elastic/elasticsearch@db6a809a66 (2026-08-12). Data as JSON: /api/errors/0c46974625ce21bc. Report an issue: GitHub.