signalapp/Signal-Server · error · IOException

failed to make http request to Cloudflare Turn

Error message

failed to make http request to Cloudflare Turn: {}

What it means

This is the request-transport failure branch of the same Cloudflare Turn credential fetch. The CompletableFuture from HttpClient.sendAsync is joined; if the underlying request failed (connection error, timeout, TLS problem), join() wraps it in a CompletionException, which is unwrapped and rethrown as an IOException. The log line records only e.getMessage() of the wrapper.

Solutions

  1. Check outbound connectivity from the server to the Cloudflare Turn endpoint (curl the URL from the host).
  2. Inspect ExceptionUtils.unwrap(e) in the thrown IOException for the root cause (ConnectException, HttpTimeoutException, SSLHandshakeException).
  3. Increase HttpClient connect/request timeouts if the failure is a timeout.
  4. Add bounded retries with backoff for transient network errors before surfacing the IOException to callers.

Example fix

// before
response = cloudflareTurnClient.sendAsync(req, HttpResponse.BodyHandlers.ofString()).join();

// after: tolerate transient network failures
for (int attempt = 0; attempt < 3; attempt++) {
  try {
    response = cloudflareTurnClient.sendAsync(req, HttpResponse.BodyHandlers.ofString()).join();
    break;
  } catch (CompletionException e) {
    if (attempt == 2) throw new IOException(ExceptionUtils.unwrap(e));
    Uninterruptibles.sleepUninterruptibly(1L << attempt, TimeUnit.SECONDS);
  }
}
Defensive patterns

Strategy: retry

Validate before calling

// pre-call reachability probe (startup health check)
HttpRequest probe = HttpRequest.newBuilder(URI.create(cloudflareTurnUrl)).method("HEAD", HttpRequest.BodyPublishers.noBody()).build();
boolean reachable = cloudflareTurnClient.sendAsync(probe, HttpResponse.BodyHandlers.discarding()).isDone();

Try / catch

try {
  creds = manager.retrieveFromCloudflare(realm, keyId, ttlSeconds);
} catch (IOException e) {
  Throwable root = ExceptionUtils.getRootCause(e);
  if (root instanceof HttpTimeoutException || root instanceof ConnectException) {
    // retry with backoff or serve cached credentials
  }
}

Prevention

When it happens

Trigger: cloudflareTurnClient.sendAsync(...).join() throws CompletionException because the HTTP call could not complete: DNS failure, connection refused/timeout, TLS handshake failure, or an interrupted join.

Common situations: Egress network restrictions from the server, Cloudflare endpoint unreachable, misconfigured proxy or firewall, DNS resolution problems, or overly short HttpClient connect timeouts.

Related errors


AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09). Data as JSON: /api/errors/7e081a5c102ef600. Report an issue: GitHub.

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManager.java:138

      turnHostname = this.cloudflareTurnHostname;
    }
    try {
      cloudflareTurnComposedUrls = dnsNameResolver.resolveAll(turnHostname).get().stream()
          .map(i -> switch (i) {
            case Inet6Address i6 -> "[" + i6.getHostAddress() + "]";
            default -> i.getHostAddress();
          })
          .flatMap(i -> turnUrlsWithIps.stream().map(u -> u.formatted(i)))
          .toList();
    } catch (Exception e) {
      throw new IOException(e);
    }

    final HttpResponse<String> response;
    try {
      response = cloudflareTurnClient.sendAsync(getCredentialsRequest, HttpResponse.BodyHandlers.ofString()).join();
    } catch (CompletionException e) {
      logger.warn("failed to make http request to Cloudflare Turn: {}", e.getMessage());
      throw new IOException(ExceptionUtils.unwrap(e));
    }

    if (response.statusCode() != Response.Status.CREATED.getStatusCode()) {
      logger.warn("failure request credentials from Cloudflare Turn (code={}): {}", response.statusCode(), response);
      throw new IOException("Cloudflare Turn http failure : " + response.statusCode());
    }

    final CloudflareTurnResponse cloudflareTurnResponse = SystemMapper.jsonMapper()
        .readValue(response.body(), CloudflareTurnResponse.class);

    return new TurnToken(
        cloudflareTurnResponse.iceServers().username(),
        cloudflareTurnResponse.iceServers().credential(),
        clientCredentialTtl.toSeconds(),
        turnUrls,
        cloudflareTurnComposedUrls,
        turnHostname

View on GitHub (pinned to 100ab61c82)