square/okhttp · error · IOException

Unexpected code

Error message

Unexpected code 

What it means

Recipe-level guard on the GET inside CheckHandshake. Because the denylist interceptor (error [6]) runs first, reaching this line means the peer certificate was NOT denylisted and chain.proceed() completed. If the resulting Response is non-2xx, java.io.IOException("Unexpected code " + response) is thrown. This is independent of the handshake check.

Source

Thrown at samples/guide/src/main/java/okhttp3/recipes/CheckHandshake.java:55

        if (denylist.contains(pin)) {
          throw new IOException("Denylisted peer certificate: " + pin);
        }
      }
      return chain.proceed(chain.request());
    }
  };

  private final OkHttpClient client = new OkHttpClient.Builder()
      .addNetworkInterceptor(CHECK_HANDSHAKE_INTERCEPTOR)
      .build();

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("https://publicobject.com/helloworld.txt")
        .build();

    try (Response response = client.newCall(request).execute()) {
      if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

      System.out.println(response.body().string());
    }
  }

  public static void main(String... args) throws Exception {
    new CheckHandshake().run();
  }
}

View on GitHub (pinned to 4fc0831380)

Solutions

  1. Inspect response.code() — it is a normal HTTP status, unrelated to certificate checking.
  2. Keep denylist handling (error [6]) and HTTP-status handling (this line) in separate catch branches.

Example fix

// before
try (Response response = client.newCall(request).execute()) {
  if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);
  ...
}

// after
try (Response response = client.newCall(request).execute()) {
  if (!response.isSuccessful()) {
    throw new IOException("HTTP " + response.code() + " (handshake passed, denylist OK)");
  }
  ...
}
Defensive patterns

Strategy: validation

Validate before calling

// Separate HTTP-status handling from the handshake/denylist policy
// After the call returns (handshake + denylist passed), branch on code:
try (Response response = client.newCall(request).execute()) {
  if (!response.isSuccessful()) {
    errors.accept(new HttpException(response.code(), response.request().url().toString()));
    return;
  }
  // use body
}

Try / catch

try (Response response = client.newCall(request).execute()) {
  if (!response.isSuccessful()) {
    // handshake passed AND denylist passed; this is a plain HTTP error
    throw new HttpException(response.code(), response.message());
  }
} catch (IOException e) {
  if (e.getMessage() != null && e.getMessage().startsWith("Denylisted")) {
    throw new SecurityPolicyException(e);
  }
  throw e;
}

Prevention

When it happens

Trigger: GET https://publicobject.com/helloworld.txt after the handshake interceptor passed. Fires for ordinary non-2xx (404, 503, etc.). A denylisted cert would have thrown at the interceptor, never reaching here.

Common situations: Conflating this line with the denylist error above; the origin returning 5xx while users blame the handshake interceptor.

Related errors


AI-assisted analysis of square/okhttp@4fc0831380 (2026-08-04). Data as JSON: /data/errors/5fd4a849cb433425.json. Report an issue: GitHub.