apache/pulsar · error · IllegalStateException

SASL authentication HTTP client is not started

Error message

SASL authentication HTTP client is not started

What it means

AuthenticationSasl lazily creates a JAX-RS Client in start(); the HttpChallengeTransport.get() method reads that field into a local and throws IllegalStateException if it is still null. This means the plugin's HTTP-based SASL challenge exchange was invoked before the authentication plugin lifecycle started (or after it was closed). The library throws eagerly rather than sending a request with an unusable client.

Source

Thrown at pulsar-client-auth-sasl/src/main/java/org/apache/pulsar/client/impl/auth/AuthenticationSasl.java:250

    /**
     * A {@link HttpChallengeTransport} backed by this plugin's own JAX-RS {@link Client} (created in
     * {@link #start()}) — the faithful transport for the admin-path SASL warmup, matching what
     * {@code authenticationStage(...)} does today. Each round is a bodiless {@code GET} to the original URI.
     * The JAX-RS client itself is created with default, unbounded timeouts, so the per-request {@code timeout}
     * is enforced here by bounding the returned future ({@link CompletableFuture#orTimeout}) AND cancelling the
     * underlying JAX-RS {@link Future} on timeout/failure — the request is bounded, so a peer that accepts the
     * connection but never responds cannot leak an in-flight request (fd/socket exhaustion) across retries. A
     * response that arrives after the future already timed out is closed rather than leaked.
     */
    private final class JaxRsChallengeTransport implements HttpChallengeTransport {
        @Override
        public CompletableFuture<Result> get(URI uri, HttpAuthHeaders requestHeaders, Duration timeout) {
            CompletableFuture<Result> future = new CompletableFuture<>();
            try {
                Client c = client;
                if (c == null) {
                    throw new IllegalStateException("SASL authentication HTTP client is not started");
                }
                Builder builder = c.target(uri).request(MediaType.APPLICATION_JSON);
                requestHeaders.asMap().forEach(builder::header);
                Future<Response> responseFuture = builder.async().get(new InvocationCallback<Response>() {
                    @Override
                    public void completed(Response response) {
                        completeAndClose(future, response);
                    }

                    @Override
                    public void failed(Throwable throwable) {
                        future.completeExceptionally(throwable);
                    }
                });
                if (timeout != null && !timeout.isNegative() && !timeout.isZero()) {
                    future.orTimeout(timeout.toNanos(), TimeUnit.NANOSECONDS);
                }
                // Cancel the unbounded JAX-RS request on timeout/failure so its socket is released, not leaked.

View on GitHub (pinned to 820761864e)

Solutions

  1. Call start() on the AuthenticationSasl instance before performing any authentication/challenge requests.
  2. Do not reuse a plugin instance after close(); create a new AuthenticationSasl (or new PulsarClient) instead.
  3. If constructing the plugin manually, replicate the full lifecycle: configure(...) then start() before get().
  4. Check for concurrent close/reuse races; synchronize shutdown so no get() can run after close().

Example fix

// before
AuthenticationSasl auth = new AuthenticationSasl();
auth.configure(authParamsString);
auth.get(uri, headers, timeout); // IllegalStateException: client not started
// after
AuthenticationSasl auth = new AuthenticationSasl();
auth.configure(authParamsString);
auth.start();
try {
    auth.get(uri, headers, timeout);
} finally {
    auth.close();
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Java: verify the plugin is started before use
// Field client is private; check lifecycle instead:
if (!started) { throw new IllegalStateException("Call start() before using AuthenticationSasl"); }

Try / catch

try {
    CompletableFuture<HttpChallengeTransport.Result> f = transport.get(uri, headers, timeout);
    f.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (ExecutionException e) {
    if (e.getCause() instanceof IllegalStateException
            && e.getCause().getMessage().contains("not started")) {
        auth.start(); // restart and retry once
    } else { throw e; }
}

Prevention

When it happens

Trigger: Calling AuthenticationSasl.get()/the challenge transport before start() has completed, using the plugin instance without initializing it (e.g. constructing it manually instead of via PulsarClient/AuthenticationProvider registry), or calling get() after close() nulled the client field (race with shutdown or reuse of a closed plugin).

Common situations: Hand-wiring the auth plugin in tests or embedded tools without calling start(); sharing a single AuthenticationSasl instance across a client that has been closed and reopened; a lifecycle ordering bug where authentication begins concurrently with/after close().

Understand the failure class

Related errors


AI-assisted analysis of apache/pulsar@820761864e (2026-09-06). Data as JSON: /api/errors/9f1c843da87aa4ba. Report an issue: GitHub.