Skip to content

Commit c702e56

Browse files
authored
Do not retry requests with a consumed streaming body (#872)
## Summary Requests with a streaming body (e.g. `files().upload()`) are backed by a single-use `InputStream`. When such a request received a retriable HTTP response (429/501/503), the retry loop in `ApiClient.executeInner` re-sent the *same* `InputStream`. Because the first attempt had already consumed the stream, the retry transmitted an **empty body**, which manifested as either: - a **0-byte file** written to the target despite an HTTP 204, or - a confusing downstream error (e.g. `InternalError`) surfaced from `FilesImpl.upload`. This only occurs when a retriable status interrupts a streaming upload, so it is rare and intermittent, but when it happens the upload silently corrupts data. ## Root cause `Request` holds the streaming body as a single `InputStream` reference. On retry, `executeInner` reuses the same `Request`, and `CommonsHttpClient` wraps that already-consumed stream in a fresh `InputStreamEntity` — sending 0 bytes. The upload `PUT` is non-idempotent, and `NonIdempotentRequestRetryStrategy` treats 429/501/503 as retriable, so a transient 503 triggers the empty-body retry. An `InputStream`-backed body cannot be rewound, so retrying is inherently unsafe once the body has been sent. ## Fix In `ApiClient.executeInner`, skip the retry when the request has a streaming body **and** an HTTP response was received (which proves the body was already transmitted), and surface the original error to the caller so it can retry with a fresh stream. The guard is deliberately narrow: - **String / no-body requests** (the vast majority of API calls) are repeatable — a new entity is built per attempt — so their retry behavior is unchanged. - **Transport-level `IOError`s** (no response received, e.g. a pre-send `ConnectException`) still retry as before, since in that case the stream may not have been read yet. The only behavior removed is the retry of an already-consumed stream, which never worked. This lives in the hand-written core (`ApiClient`), so it applies to any streaming upload rather than being specific to the Files API. ## Tests - `doesNotRetryStreamingBodyAfterResponse` — a streaming `PUT` that gets a 503 now throws the typed `TemporarilyUnavailable` (503) instead of silently retrying with an empty body. - `retriesNonStreamingBodyOn503` — a string-bodied request with the same 503 still retries and succeeds, confirming the guard does not regress ordinary requests. Full module test suite passes (1404 tests, 0 failures).
1 parent 23b0be2 commit c702e56

3 files changed

Lines changed: 122 additions & 0 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
### Bug Fixes
1010

11+
* Fixed requests with a streaming body (e.g. `files().upload()`) silently uploading an empty body when retried. A single-use `InputStream` body is consumed by the first attempt, so retrying a retriable error (e.g. HTTP 503) re-sent an empty stream, which could write a 0-byte file or surface as a confusing error. The SDK no longer retries a streaming request once its body has been sent, and instead surfaces the original error so the caller can retry with a fresh stream.
12+
1113
### Security Vulnerabilities
1214

1315
### Documentation

databricks-sdk-java/src/main/java/com/databricks/sdk/core/ApiClient.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,20 @@ private Response executeInner(Request in, String path, RequestOptions options) {
275275
if (!retryStrategy.isRetriable(databricksError)) {
276276
throw databricksError;
277277
}
278+
279+
// A streaming request body (e.g. Files.upload) is backed by a single-use InputStream that the
280+
// first attempt consumes as it is sent. Receiving an HTTP response (response != null) means
281+
// the body was already sent, so retrying would re-send an empty stream and upload 0 bytes.
282+
// The stream cannot be rewound, so surface the original error and let the caller retry with a
283+
// fresh stream. Transport IOErrors (response == null) still retry, as the body may be unsent.
284+
if (in.isBodyStreaming() && response != null) {
285+
LOG.debug(
286+
"Not retrying {} despite a retriable error: the request has a non-repeatable streaming"
287+
+ " body that was already consumed by the previous attempt",
288+
in.getRequestLine());
289+
throw databricksError;
290+
}
291+
278292
if (attemptNumber == maxAttempts) {
279293
throw new DatabricksException(
280294
String.format("Request %s failed after %d retries", in, maxAttempts), databricksError);

databricks-sdk-java/src/test/java/com/databricks/sdk/core/ApiClientTest.java

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import com.databricks.sdk.core.error.PrivateLinkValidationError;
77
import com.databricks.sdk.core.error.details.ErrorDetails;
88
import com.databricks.sdk.core.error.details.ErrorInfo;
9+
import com.databricks.sdk.core.error.platform.TemporarilyUnavailable;
910
import com.databricks.sdk.core.error.platform.TooManyRequests;
11+
import com.databricks.sdk.core.http.HttpClient;
1012
import com.databricks.sdk.core.http.Request;
1113
import com.databricks.sdk.core.http.Response;
1214
import com.databricks.sdk.core.utils.FakeTimer;
@@ -15,10 +17,13 @@
1517
import com.fasterxml.jackson.core.JsonProcessingException;
1618
import com.fasterxml.jackson.databind.ObjectMapper;
1719
import com.google.errorprone.annotations.CanIgnoreReturnValue;
20+
import java.io.ByteArrayInputStream;
1821
import java.io.IOException;
22+
import java.io.InputStream;
1923
import java.net.MalformedURLException;
2024
import java.net.URL;
2125
import java.net.UnknownHostException;
26+
import java.nio.charset.StandardCharsets;
2227
import java.time.*;
2328
import java.util.*;
2429
import org.apache.http.impl.EnglishReasonPhraseCatalog;
@@ -484,6 +489,107 @@ void privateLinkRedirectBecomesPrivateLinkValidationError() throws MalformedURLE
484489
assertTrue(e.getMessage().contains("AWS PrivateLink"));
485490
}
486491

492+
/**
493+
* A fake HttpClient that reads the request body to EOF on every call, mirroring how the real
494+
* CommonsHttpClient drains the entity onto the wire. It records the number of body bytes actually
495+
* transmitted per attempt, so tests can assert what a retry would (or would not) send. The status
496+
* code returned for each attempt is supplied up front.
497+
*/
498+
private static class BodyReadingHttpClient implements HttpClient {
499+
private final Deque<Integer> statusCodes;
500+
final List<Integer> bytesReadPerAttempt = new ArrayList<>();
501+
502+
BodyReadingHttpClient(Integer... statusCodesInOrder) {
503+
this.statusCodes = new ArrayDeque<>(Arrays.asList(statusCodesInOrder));
504+
}
505+
506+
@Override
507+
public Response execute(Request in) throws IOException {
508+
// The SDK issues a best-effort GET /.well-known/databricks-config host-metadata pre-flight
509+
// through this same client before the request under test. Ignore it: return a benign 404
510+
// (the SDK falls back to user config) without recording it or consuming a status code.
511+
if (in.getUrl().contains("/.well-known/")) {
512+
return new Response(in, 404, "Not Found", Collections.emptyMap());
513+
}
514+
int total = 0;
515+
if (in.isBodyStreaming() && in.getBodyStream() != null) {
516+
InputStream is = in.getBodyStream();
517+
byte[] buf = new byte[4096];
518+
int r;
519+
while ((r = is.read(buf)) != -1) {
520+
total += r;
521+
}
522+
} else if (in.isBodyString() && in.getBodyString() != null) {
523+
total = in.getBodyString().getBytes(StandardCharsets.UTF_8).length;
524+
}
525+
bytesReadPerAttempt.add(total);
526+
int status = statusCodes.isEmpty() ? 204 : statusCodes.removeFirst();
527+
String reason = EnglishReasonPhraseCatalog.INSTANCE.getReason(status, Locale.ENGLISH);
528+
return new Response(in, status, reason, Collections.emptyMap());
529+
}
530+
}
531+
532+
private ApiClient apiClientWith(HttpClient httpClient) {
533+
DatabricksConfig config =
534+
new DatabricksConfig()
535+
.setHost("http://my.host")
536+
.setCredentialsProvider(new DummyCredentialsProvider())
537+
.setHttpClient(httpClient);
538+
return new ApiClient(config, new FakeTimer());
539+
}
540+
541+
@Test
542+
void doesNotRetryStreamingBodyAfterResponse() throws IOException {
543+
// A streaming body (e.g. Files.upload) is a single-use InputStream consumed by the first
544+
// attempt, so retrying a 503 would re-send an empty stream and silently upload 0 bytes. The
545+
// upload must be attempted exactly once and the 503 surfaced to the caller.
546+
byte[] contents = "file-contents".getBytes(StandardCharsets.UTF_8);
547+
// The 204 is what a buggy empty-body retry would receive; the guard means it is never reached.
548+
BodyReadingHttpClient hc = new BodyReadingHttpClient(503, 204);
549+
ApiClient client = apiClientWith(hc);
550+
551+
InputStream body = new ByteArrayInputStream(contents);
552+
// Catch rather than assertThrows so the byte-count assertions below run first: without the fix
553+
// no exception is thrown, and those assertions give the more informative [13, 0] failure.
554+
DatabricksError thrown = null;
555+
try {
556+
client.execute(new Request("PUT", "/api/2.0/fs/files/Volumes/c/s/v/f", body), Void.class);
557+
} catch (DatabricksError e) {
558+
thrown = e;
559+
}
560+
561+
// Exactly one attempt, sending the full body. Without the guard this is [13, 0]: a full first
562+
// attempt followed by an empty-body retry.
563+
assertEquals(
564+
1,
565+
hc.bytesReadPerAttempt.size(),
566+
"streaming upload must not be retried; bytes sent per attempt: " + hc.bytesReadPerAttempt);
567+
assertEquals(contents.length, hc.bytesReadPerAttempt.get(0));
568+
assertEquals(-1, body.read(), "stream is single-use and should be fully consumed");
569+
assertInstanceOf(TemporarilyUnavailable.class, thrown);
570+
assertEquals(503, thrown.getStatusCode());
571+
}
572+
573+
@Test
574+
void retriesNonStreamingBodyOn503AndResendsFullBody() throws IOException {
575+
// Complement to doesNotRetryStreamingBodyAfterResponse: a string-bodied request is repeatable,
576+
// so the same 503 must still be retried, and crucially the retry must re-send the full body
577+
// (a fresh entity is built per attempt). This confirms the streaming guard is scoped narrowly
578+
// and does not regress ordinary requests.
579+
String jsonBody = "{\"key\":\"value\"}";
580+
BodyReadingHttpClient hc = new BodyReadingHttpClient(503, 200);
581+
ApiClient client = apiClientWith(hc);
582+
583+
client.execute(
584+
new Request("POST", "/api/2.0/sql/statements/", jsonBody), MyEndpointResponse.class);
585+
586+
// The 503 was retried, and both attempts sent the full body (a string body is re-sendable).
587+
int expected = jsonBody.getBytes(StandardCharsets.UTF_8).length;
588+
assertEquals(2, hc.bytesReadPerAttempt.size());
589+
assertEquals(expected, hc.bytesReadPerAttempt.get(0));
590+
assertEquals(expected, hc.bytesReadPerAttempt.get(1));
591+
}
592+
487593
@Test
488594
void testDefaultWorkspaceIdReturnsNullWhenNotSet() {
489595
Request req = getBasicRequest();

0 commit comments

Comments
 (0)