Skip to content

Commit fea1001

Browse files
authored
Merge pull request #2059 from atsushieno/avoid-buffered-response-stream
Default GitHubConnectorResponse to streamed body instead of in-memory buffer
2 parents 7e02afa + 350f1bc commit fea1001

File tree

8 files changed

+372
-105
lines changed

8 files changed

+372
-105
lines changed

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,7 @@
611611
</configuration>
612612
</execution>
613613
<execution>
614-
<id>httpclient-test</id>
614+
<id>httpclient-test-tracing</id>
615615
<phase>integration-test</phase>
616616
<goals>
617617
<goal>test</goal>

src/main/java/org/kohsuke/github/GitHubClient.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,7 @@ private void logResponseBody(@Nonnull final GitHubConnectorResponse response) {
629629
LOGGER.log(FINEST, () -> {
630630
String body;
631631
try {
632+
response.setBodyStreamRereadable();
632633
body = GitHubResponse.getBodyAsString(response);
633634
} catch (Throwable e) {
634635
body = "Error reading response body";

src/main/java/org/kohsuke/github/connector/GitHubConnectorResponse.java

Lines changed: 128 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,25 @@
77
import java.io.Closeable;
88
import java.io.IOException;
99
import java.io.InputStream;
10-
import java.util.*;
10+
import java.util.ArrayList;
11+
import java.util.Collections;
12+
import java.util.Comparator;
13+
import java.util.List;
14+
import java.util.Map;
15+
import java.util.TreeMap;
1116
import java.util.zip.GZIPInputStream;
1217

1318
import javax.annotation.CheckForNull;
1419
import javax.annotation.Nonnull;
1520

21+
import static java.net.HttpURLConnection.HTTP_OK;
22+
1623
/**
1724
* Response information supplied when a response is received and before the body is processed.
1825
* <p>
26+
* During a request to GitHub, {@link GitHubConnector#send(GitHubConnectorRequest)} returns a
27+
* {@link GitHubConnectorResponse}. This is processed to create a GitHubResponse.
28+
* <p>
1929
* Instances of this class are closed once the response is done being processed. This means that {@link #bodyStream()}
2030
* will not be readable after a call is completed.
2131
*
@@ -35,6 +45,11 @@ public abstract class GitHubConnectorResponse implements Closeable {
3545
private final GitHubConnectorRequest request;
3646
@Nonnull
3747
private final Map<String, List<String>> headers;
48+
private boolean bodyStreamCalled = false;
49+
private InputStream bodyStream = null;
50+
private byte[] bodyBytes = null;
51+
private boolean isClosed = false;
52+
private boolean isBodyStreamRereadable;
3853

3954
/**
4055
* GitHubConnectorResponse constructor
@@ -58,6 +73,7 @@ protected GitHubConnectorResponse(@Nonnull GitHubConnectorRequest request,
5873
caseInsensitiveMap.put(entry.getKey(), Collections.unmodifiableList(new ArrayList<>(entry.getValue())));
5974
}
6075
this.headers = Collections.unmodifiableMap(caseInsensitiveMap);
76+
this.isBodyStreamRereadable = false;
6177
}
6278

6379
/**
@@ -79,17 +95,72 @@ public String header(String name) {
7995
/**
8096
* The response body as an {@link InputStream}.
8197
*
98+
* When {@link #isBodyStreamRereadable} is false, {@link #bodyStream()} can only be called once and the returned
99+
* stream should be assumed to be read-once and not resetable. This is the default behavior for HTTP_OK responses
100+
* and significantly reduces memory usage.
101+
*
102+
* When {@link #isBodyStreamRereadable} is true, {@link #bodyStream()} can be called be called multiple times. The
103+
* full stream data is read into a byte array during the first call. Each call returns a new stream backed by the
104+
* same byte array. This uses more memory, but is required to enable rereading the body stream during trace logging,
105+
* debugging, and error responses.
106+
*
82107
* @return the response body
83108
* @throws IOException
84109
* if response stream is null or an I/O Exception occurs.
85110
*/
86111
@Nonnull
87-
public abstract InputStream bodyStream() throws IOException;
112+
public InputStream bodyStream() throws IOException {
113+
synchronized (this) {
114+
if (isClosed) {
115+
throw new IOException("Response is closed");
116+
}
117+
118+
if (bodyStreamCalled) {
119+
if (!isBodyStreamRereadable()) {
120+
throw new IOException("Response body not rereadable");
121+
}
122+
} else {
123+
bodyStream = wrapStream(rawBodyStream());
124+
bodyStreamCalled = true;
125+
}
126+
127+
if (bodyStream == null) {
128+
throw new IOException("Response body missing, stream null");
129+
} else if (!isBodyStreamRereadable()) {
130+
return bodyStream;
131+
}
132+
133+
// Load rereadable byte array
134+
if (bodyBytes == null) {
135+
bodyBytes = IOUtils.toByteArray(bodyStream);
136+
// Close the raw body stream after successfully reading
137+
IOUtils.closeQuietly(bodyStream);
138+
}
139+
140+
return new ByteArrayInputStream(bodyBytes);
141+
}
142+
}
88143

89144
/**
90-
* Gets the {@link GitHubConnectorRequest} for this response.
145+
* Get the raw implementation specific body stream for this response.
146+
*
147+
* This method will only be called once to completion. If an exception is thrown by this method, it may be called
148+
* multiple times.
91149
*
92-
* @return the {@link GitHubConnectorRequest} for this response.
150+
* The stream returned from this method will be closed when the response is closed or sooner. Inheriting classes do
151+
* not need to close it.
152+
*
153+
* @return the stream for the raw response
154+
* @throws IOException
155+
* if an I/O Exception occurs.
156+
*/
157+
@CheckForNull
158+
protected abstract InputStream rawBodyStream() throws IOException;
159+
160+
/**
161+
* Gets the {@link GitHubConnector} for this response.
162+
*
163+
* @return the {@link GitHubConnector} for this response.
93164
*/
94165
@Nonnull
95166
public GitHubConnectorRequest request() {
@@ -116,6 +187,56 @@ public Map<String, List<String>> allHeaders() {
116187
return headers;
117188
}
118189

190+
/**
191+
* The body stream rereadable state.
192+
*
193+
* Body stream defaults to read once for HTTP_OK responses (to reduce memory usage). For non-HTTP_OK responses, body
194+
* stream is switched to rereadable (in-memory byte array) for error processing.
195+
*
196+
* Calling {@link #setBodyStreamRereadable()} will force {@link #isBodyStreamRereadable} to be true for this
197+
* response regardless of {@link #statusCode} value.
198+
*
199+
* @return true when body stream is rereadable.
200+
*/
201+
public boolean isBodyStreamRereadable() {
202+
synchronized (this) {
203+
return isBodyStreamRereadable || statusCode != HTTP_OK;
204+
}
205+
}
206+
207+
/**
208+
* Force body stream to rereadable regardless of status code.
209+
*
210+
* Calling {@link #setBodyStreamRereadable()} will force {@link #isBodyStreamRereadable} to be true for this
211+
* response regardless of {@link #statusCode} value.
212+
*
213+
* This is required to support body value logging during low-level tracing but should be avoided in general since it
214+
* consumes significantly more memory.
215+
*
216+
* Will throw runtime exception if a non-rereadable body stream has already been returned from
217+
* {@link #bodyStream()}.
218+
*/
219+
public void setBodyStreamRereadable() {
220+
synchronized (this) {
221+
if (bodyStreamCalled && !isBodyStreamRereadable()) {
222+
throw new RuntimeException("bodyStream() already called in read-once mode");
223+
}
224+
isBodyStreamRereadable = true;
225+
}
226+
}
227+
228+
/**
229+
* {@inheritDoc}
230+
*/
231+
@Override
232+
public void close() throws IOException {
233+
synchronized (this) {
234+
IOUtils.closeQuietly(bodyStream);
235+
isClosed = true;
236+
this.bodyBytes = null;
237+
}
238+
}
239+
119240
/**
120241
* Handles wrapping the body stream if indicated by the "Content-Encoding" header.
121242
*
@@ -155,13 +276,12 @@ public final int parseInt(String name) throws NumberFormatException {
155276

156277
/**
157278
* A ByteArrayResponse class
279+
*
280+
* @deprecated Inherit directly from {@link GitHubConnectorResponse}.
158281
*/
282+
@Deprecated
159283
public abstract static class ByteArrayResponse extends GitHubConnectorResponse {
160284

161-
private boolean inputStreamRead = false;
162-
private byte[] inputBytes = null;
163-
private boolean isClosed = false;
164-
165285
/**
166286
* Constructor for ByteArray Response
167287
*
@@ -177,52 +297,5 @@ protected ByteArrayResponse(@Nonnull GitHubConnectorRequest request,
177297
@Nonnull Map<String, List<String>> headers) {
178298
super(request, statusCode, headers);
179299
}
180-
181-
/**
182-
* {@inheritDoc}
183-
*/
184-
@Override
185-
@Nonnull
186-
public InputStream bodyStream() throws IOException {
187-
if (isClosed) {
188-
throw new IOException("Response is closed");
189-
}
190-
synchronized (this) {
191-
if (!inputStreamRead) {
192-
InputStream rawStream = rawBodyStream();
193-
try (InputStream stream = wrapStream(rawStream)) {
194-
if (stream != null) {
195-
inputBytes = IOUtils.toByteArray(stream);
196-
}
197-
}
198-
inputStreamRead = true;
199-
}
200-
}
201-
202-
if (inputBytes == null) {
203-
throw new IOException("Response body missing, stream null");
204-
}
205-
206-
return new ByteArrayInputStream(inputBytes);
207-
}
208-
209-
/**
210-
* Get the raw implementation specific body stream for this response.
211-
*
212-
* This method will only be called once to completion. If an exception is thrown, it may be called multiple
213-
* times.
214-
*
215-
* @return the stream for the raw response
216-
* @throws IOException
217-
* if an I/O Exception occurs.
218-
*/
219-
@CheckForNull
220-
protected abstract InputStream rawBodyStream() throws IOException;
221-
222-
@Override
223-
public void close() throws IOException {
224-
isClosed = true;
225-
this.inputBytes = null;
226-
}
227300
}
228301
}

src/main/java/org/kohsuke/github/extras/HttpClientGitHubConnector.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public GitHubConnectorResponse send(GitHubConnectorRequest connectorRequest) thr
9393
*
9494
* Implementation specific to {@link HttpResponse}.
9595
*/
96-
private static class HttpClientGitHubConnectorResponse extends GitHubConnectorResponse.ByteArrayResponse {
96+
private static class HttpClientGitHubConnectorResponse extends GitHubConnectorResponse {
9797

9898
@Nonnull
9999
private final HttpResponse<InputStream> response;
@@ -113,7 +113,6 @@ protected InputStream rawBodyStream() throws IOException {
113113
@Override
114114
public void close() throws IOException {
115115
super.close();
116-
IOUtils.closeQuietly(response.body());
117116
}
118117
}
119118
}

src/main/java/org/kohsuke/github/extras/okhttp3/OkHttpGitHubConnector.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ private List<ConnectionSpec> TlsConnectionSpecs() {
103103
*
104104
* Implementation specific to {@link okhttp3.Response}.
105105
*/
106-
private static class OkHttpGitHubConnectorResponse extends GitHubConnectorResponse.ByteArrayResponse {
106+
private static class OkHttpGitHubConnectorResponse extends GitHubConnectorResponse {
107107

108108
@Nonnull
109109
private final Response response;

src/test/java/org/kohsuke/github/GHWorkflowRunTest.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.kohsuke.github.GHWorkflowRun.Status;
1010
import org.kohsuke.github.function.InputStreamFunction;
1111

12+
import java.io.ByteArrayInputStream;
1213
import java.io.IOException;
1314
import java.time.Duration;
1415
import java.time.Instant;
@@ -18,6 +19,8 @@
1819
import java.util.Optional;
1920
import java.util.Scanner;
2021
import java.util.function.Function;
22+
import java.util.logging.Level;
23+
import java.util.logging.Logger;
2124
import java.util.stream.Collectors;
2225
import java.util.zip.ZipEntry;
2326
import java.util.zip.ZipInputStream;
@@ -393,8 +396,14 @@ public void testArtifacts() throws IOException {
393396
checkArtifactProperties(artifacts.get(0), "artifact1");
394397
checkArtifactProperties(artifacts.get(1), "artifact2");
395398

399+
Logger clientLogger = Logger.getLogger(GitHubClient.class.getName());
400+
396401
// Test download from upload-artifact@v3 infrastructure
397402
String artifactContent = artifacts.get(0).download((is) -> {
403+
// At finest log level, all body responses are byte arrays.
404+
if (clientLogger.getLevel() != Level.FINEST) {
405+
assertThat(is, not(isA(ByteArrayInputStream.class)));
406+
}
398407
try (ZipInputStream zis = new ZipInputStream(is)) {
399408
StringBuilder sb = new StringBuilder();
400409

0 commit comments

Comments
 (0)