Skip to content

Commit c634447

Browse files
author
Ryan Dew
committed
MLE-32970 Fix multipart streaming input to data services
1 parent 1d19510 commit c634447

4 files changed

Lines changed: 107 additions & 2 deletions

File tree

‎marklogic-client-api/src/main/java/com/marklogic/client/impl/OkHttpServices.java‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5194,7 +5194,7 @@ static private List<BodyPart> getPartList(MimeMultipart multipart) {
51945194
}
51955195
}
51965196

5197-
static private class ObjectRequestBody extends RequestBody implements RetryableRequestBody {
5197+
static class ObjectRequestBody extends RequestBody implements RetryableRequestBody {
51985198

51995199
private Object obj;
52005200
private MediaType contentType;
@@ -5236,6 +5236,15 @@ public boolean isRetryable() {
52365236
// InputStreams cannot be retried as they are consumed on first read.
52375237
return !(obj instanceof InputStream);
52385238
}
5239+
5240+
@Override
5241+
public boolean isOneShot() {
5242+
// Declares an InputStream-backed body as one-shot via OkHttp's own contract so that when this body is
5243+
// nested inside another RequestBody (e.g. a MultipartBody part), OkHttp and RetryIOExceptionInterceptor
5244+
// both recognize the outer body as non-retryable too; isRetryable() above is only checked when this is the
5245+
// top-level request body and would otherwise be bypassed for a nested InputStream part.
5246+
return obj instanceof InputStream;
5247+
}
52395248
}
52405249

52415250
// API First Changes

‎marklogic-client-api/src/main/java/com/marklogic/client/impl/StreamingOutputImpl.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,13 @@ public boolean isRetryable() {
5353
// Added in 8.0.0; streaming output cannot be retried as the stream is consumed on first write.
5454
return false;
5555
}
56+
57+
@Override
58+
public boolean isOneShot() {
59+
// Declares this body as one-shot via OkHttp's own contract so that when this body is nested inside another
60+
// RequestBody (e.g. a MultipartBody part for a Data Services call), OkHttp and RetryIOExceptionInterceptor
61+
// both recognize the outer body as non-retryable too; isRetryable() above is only checked when this is the
62+
// top-level request body and would otherwise be bypassed for a nested streaming part.
63+
return true;
64+
}
5665
}

‎marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/RetryIOExceptionInterceptor.java‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.marklogic.client.MarkLogicIOException;
77
import okhttp3.Interceptor;
88
import okhttp3.Request;
9+
import okhttp3.RequestBody;
910
import okhttp3.Response;
1011
import org.slf4j.Logger;
1112

@@ -40,8 +41,10 @@ public RetryIOExceptionInterceptor(int maxRetries, long initialDelayMs, double b
4041
@Override
4142
public Response intercept(Chain chain) throws IOException {
4243
Request request = chain.request();
44+
RequestBody requestBody = request.body();
4345

44-
if (request.body() instanceof RetryableRequestBody body && !body.isRetryable()) {
46+
if (requestBody != null && (requestBody.isOneShot() ||
47+
requestBody instanceof RetryableRequestBody body && !body.isRetryable())) {
4548
return chain.proceed(request);
4649
}
4750

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright (c) 2010-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
3+
*/
4+
package com.marklogic.client.impl;
5+
6+
import com.marklogic.client.impl.okhttp.RetryIOExceptionInterceptor;
7+
import okhttp3.MediaType;
8+
import okhttp3.MultipartBody;
9+
import okhttp3.OkHttpClient;
10+
import okhttp3.Request;
11+
import org.junit.jupiter.api.Test;
12+
13+
import java.io.ByteArrayInputStream;
14+
import java.io.IOException;
15+
import java.io.InputStream;
16+
import java.nio.charset.StandardCharsets;
17+
import java.util.concurrent.atomic.AtomicInteger;
18+
19+
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertThrows;
21+
import static org.junit.jupiter.api.Assertions.assertTrue;
22+
23+
class StreamingOutputImplTest {
24+
25+
@Test
26+
void multipartBodyWithStreamingPartIsNotRetried() {
27+
StreamingOutputImpl streamingBody = new StreamingOutputImpl(
28+
outputStream -> outputStream.write("content".getBytes(StandardCharsets.UTF_8)),
29+
null,
30+
MediaType.get("application/xml")
31+
);
32+
MultipartBody multipartBody = new MultipartBody.Builder()
33+
.setType(MultipartBody.FORM)
34+
.addFormDataPart("body", null, streamingBody)
35+
.build();
36+
assertTrue(multipartBody.isOneShot());
37+
38+
AtomicInteger attempts = new AtomicInteger();
39+
OkHttpClient client = new OkHttpClient.Builder()
40+
.addInterceptor(new RetryIOExceptionInterceptor(3, 0, 1, 0))
41+
.addInterceptor(chain -> {
42+
attempts.incrementAndGet();
43+
throw new IOException("unexpected end of stream");
44+
})
45+
.build();
46+
Request request = new Request.Builder()
47+
.url("http://localhost/")
48+
.post(multipartBody)
49+
.build();
50+
51+
assertThrows(IOException.class, () -> client.newCall(request).execute());
52+
assertEquals(1, attempts.get());
53+
}
54+
55+
@Test
56+
void multipartBodyWithInputStreamPartIsNotRetried() {
57+
InputStream inputStream = new ByteArrayInputStream("content".getBytes(StandardCharsets.UTF_8));
58+
OkHttpServices.ObjectRequestBody inputStreamBody =
59+
new OkHttpServices.ObjectRequestBody(inputStream, MediaType.get("application/xml"));
60+
MultipartBody multipartBody = new MultipartBody.Builder()
61+
.setType(MultipartBody.FORM)
62+
.addFormDataPart("body", null, inputStreamBody)
63+
.build();
64+
assertTrue(multipartBody.isOneShot(),
65+
"A multipart body containing an InputStream-backed part must be one-shot so the retry interceptor does " +
66+
"not replay an already-consumed stream.");
67+
68+
AtomicInteger attempts = new AtomicInteger();
69+
OkHttpClient client = new OkHttpClient.Builder()
70+
.addInterceptor(new RetryIOExceptionInterceptor(3, 0, 1, 0))
71+
.addInterceptor(chain -> {
72+
attempts.incrementAndGet();
73+
throw new IOException("unexpected end of stream");
74+
})
75+
.build();
76+
Request request = new Request.Builder()
77+
.url("http://localhost/")
78+
.post(multipartBody)
79+
.build();
80+
81+
assertThrows(IOException.class, () -> client.newCall(request).execute());
82+
assertEquals(1, attempts.get());
83+
}
84+
}

0 commit comments

Comments
 (0)