From 1a0f2ef54f8f34b6c8265f82535e489230e06a02 Mon Sep 17 00:00:00 2001 From: Andrew Kent Date: Fri, 13 Nov 2026 14:43:18 -0700 Subject: [PATCH] fix: use HTTP/1.1 for plaintext API endpoints --- .../api/BraintrustOpenApiClient.java | 7 ++- .../api/BraintrustOpenApiClientTest.java | 63 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustOpenApiClient.java b/braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustOpenApiClient.java index 5c388ab9..baf6f4b6 100644 --- a/braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustOpenApiClient.java +++ b/braintrust-sdk/src/main/java/dev/braintrust/api/BraintrustOpenApiClient.java @@ -32,7 +32,12 @@ private BraintrustOpenApiClient(BraintrustConfig config) { super(); this.config = config; this.updateBaseUri(config.apiUrl()); - this.setHttpClientBuilder(HttpClient.newBuilder().sslContext(config.sslContext())); + var httpClientBuilder = HttpClient.newBuilder().sslContext(config.sslContext()); + if ("http".equalsIgnoreCase(URI.create(config.apiUrl()).getScheme())) { + // Some HTTP servers disconnect h2c upgrade requests instead of serving HTTP/1.1. + httpClientBuilder.version(HttpClient.Version.HTTP_1_1); + } + this.setHttpClientBuilder(httpClientBuilder); this.setRequestInterceptor(req -> req.header("Authorization", "Bearer " + config.apiKey())); } diff --git a/braintrust-sdk/src/test/java/dev/braintrust/api/BraintrustOpenApiClientTest.java b/braintrust-sdk/src/test/java/dev/braintrust/api/BraintrustOpenApiClientTest.java index 85a4322f..8936635f 100644 --- a/braintrust-sdk/src/test/java/dev/braintrust/api/BraintrustOpenApiClientTest.java +++ b/braintrust-sdk/src/test/java/dev/braintrust/api/BraintrustOpenApiClientTest.java @@ -6,8 +6,15 @@ import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.sun.net.httpserver.HttpServer; import dev.braintrust.config.BraintrustConfig; import java.io.FileInputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyStore; @@ -131,6 +138,62 @@ void of_addsAuthorizationHeader() throws Exception { .withHeader("Authorization", equalTo("Bearer test-key"))); } + @Test + void of_supportsHttpServersThatDisconnectUpgradeRequests() throws Exception { + var server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext( + "/v1/project", + exchange -> { + try (exchange) { + if (exchange.getRequestHeaders().containsKey("Upgrade")) { + return; + } + var body = "{\"objects\":[]}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } + }); + server.start(); + try { + var httpClient = + BraintrustOpenApiClient.of( + BraintrustConfig.builder() + .apiKey("test-key") + .apiUrl("http://localhost:" + server.getAddress().getPort()) + .build()); + var projects = + new dev.braintrust.openapi.api.ProjectsApi(httpClient) + .getProject(null, null, null, null, "any", null); + assertTrue(projects.getObjects().isEmpty()); + } finally { + server.stop(0); + } + } + + @Test + void of_preservesHttp2ForHttps() throws Exception { + wireMockHttps.stubFor(get(urlEqualTo("/protocol")).willReturn(aResponse().withStatus(204))); + var httpsUrl = "https://localhost:" + wireMockHttps.httpsPort(); + var httpsClient = + BraintrustOpenApiClient.of( + BraintrustConfig.builder() + .apiKey("test-key") + .apiUrl(httpsUrl) + .sslContext(customSslContext) + .build()); + var response = + httpsClient + .getHttpClient() + .send( + HttpRequest.newBuilder(URI.create(httpsUrl + "/protocol")) + .GET() + .build(), + HttpResponse.BodyHandlers.discarding()); + assertEquals(204, response.statusCode()); + assertEquals(HttpClient.Version.HTTP_2, response.version()); + } + @Test void of_usesSslContextFromConfig() throws Exception { // WireMock's HTTPS port uses a self-signed cert that the default SSLContext