Skip to content

Commit 2925326

Browse files
Improve Docker API 5xx error messages
This commit improves the error messages returned by the Spring Boot build plugins when a 5xx status code is returned from the Docker API while attempting to build an image. If the error response has contents containing a JSON structure with a "message" key, the value associated with that key will be included in the exception message and in the build plugin output error. Fixes gh-21515
1 parent 1486ce5 commit 2925326

File tree

7 files changed

+202
-25
lines changed

7 files changed

+202
-25
lines changed

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/DockerEngineException.java

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.net.URI;
2020

2121
import org.springframework.util.Assert;
22+
import org.springframework.util.StringUtils;
2223

2324
/**
2425
* Exception thrown when a call to the Docker API fails.
@@ -35,11 +36,15 @@ public class DockerEngineException extends RuntimeException {
3536

3637
private final Errors errors;
3738

38-
DockerEngineException(String host, URI uri, int statusCode, String reasonPhrase, Errors errors) {
39-
super(buildMessage(host, uri, statusCode, reasonPhrase, errors));
39+
private final Message responseMessage;
40+
41+
DockerEngineException(String host, URI uri, int statusCode, String reasonPhrase, Errors errors,
42+
Message responseMessage) {
43+
super(buildMessage(host, uri, statusCode, reasonPhrase, errors, responseMessage));
4044
this.statusCode = statusCode;
4145
this.reasonPhrase = reasonPhrase;
4246
this.errors = errors;
47+
this.responseMessage = responseMessage;
4348
}
4449

4550
/**
@@ -51,32 +56,45 @@ public int getStatusCode() {
5156
}
5257

5358
/**
54-
* Return the reason phrase returned by the Docker API error.
59+
* Return the reason phrase returned by the Docker API.
5560
* @return the reasonPhrase
5661
*/
5762
public String getReasonPhrase() {
5863
return this.reasonPhrase;
5964
}
6065

6166
/**
62-
* Return the Errors from the body of the Docker API error, or {@code null} if the
63-
* error JSON could not be read.
67+
* Return the errors from the body of the Docker API response, or {@code null} if the
68+
* errors JSON could not be read.
6469
* @return the errors or {@code null}
6570
*/
6671
public Errors getErrors() {
6772
return this.errors;
6873
}
6974

70-
private static String buildMessage(String host, URI uri, int statusCode, String reasonPhrase, Errors errors) {
71-
Assert.notNull(host, "host must not be null");
75+
/**
76+
* Return the message from the body of the Docker API response, or {@code null} if the
77+
* message JSON could not be read.
78+
* @return the message or {@code null}
79+
*/
80+
public Message getResponseMessage() {
81+
return this.responseMessage;
82+
}
83+
84+
private static String buildMessage(String host, URI uri, int statusCode, String reasonPhrase, Errors errors,
85+
Message responseMessage) {
86+
Assert.notNull(host, "Host must not be null");
7287
Assert.notNull(uri, "URI must not be null");
7388
StringBuilder message = new StringBuilder(
7489
"Docker API call to '" + host + uri + "' failed with status code " + statusCode);
75-
if (reasonPhrase != null && !reasonPhrase.isEmpty()) {
76-
message.append(" \"" + reasonPhrase + "\"");
90+
if (!StringUtils.isEmpty(reasonPhrase)) {
91+
message.append(" \"").append(reasonPhrase).append("\"");
92+
}
93+
if (responseMessage != null && !StringUtils.isEmpty(responseMessage.getMessage())) {
94+
message.append(" and message \"").append(responseMessage.getMessage()).append("\"");
7795
}
7896
if (errors != null && !errors.isEmpty()) {
79-
message.append(" " + errors);
97+
message.append(" ").append(errors);
8098
}
8199
return message.toString();
82100
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/transport/HttpClientTransport.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,9 @@ private Response execute(HttpUriRequest request) {
131131
HttpEntity entity = response.getEntity();
132132
if (statusCode >= 400 && statusCode <= 500) {
133133
Errors errors = (statusCode != 500) ? getErrorsFromResponse(entity) : null;
134+
Message message = getMessageFromResponse(entity);
134135
throw new DockerEngineException(this.host.toHostString(), request.getURI(), statusCode,
135-
statusLine.getReasonPhrase(), errors);
136+
statusLine.getReasonPhrase(), errors, message);
136137
}
137138
return new HttpClientResponse(response);
138139
}
@@ -150,6 +151,16 @@ private Errors getErrorsFromResponse(HttpEntity entity) {
150151
}
151152
}
152153

154+
private Message getMessageFromResponse(HttpEntity entity) {
155+
try {
156+
return (entity.getContent() != null)
157+
? SharedObjectMapper.get().readValue(entity.getContent(), Message.class) : null;
158+
}
159+
catch (IOException ex) {
160+
return null;
161+
}
162+
}
163+
153164
HttpHost getHost() {
154165
return this.host;
155166
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright 2012-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.buildpack.platform.docker.transport;
18+
19+
import com.fasterxml.jackson.annotation.JsonCreator;
20+
import com.fasterxml.jackson.annotation.JsonProperty;
21+
22+
/**
23+
* A message returned from the Docker API.
24+
*
25+
* @author Scott Frederick
26+
* @since 2.3.1
27+
*/
28+
public class Message {
29+
30+
private final String message;
31+
32+
@JsonCreator
33+
Message(@JsonProperty("message") String message) {
34+
this.message = message;
35+
}
36+
37+
/**
38+
* Return the message contained in the response.
39+
* @return the message
40+
*/
41+
public String getMessage() {
42+
return this.message;
43+
}
44+
45+
@Override
46+
public String toString() {
47+
return this.message;
48+
}
49+
50+
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/DockerEngineExceptionTests.java

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,55 +49,78 @@ class DockerEngineExceptionTests {
4949

5050
private static final Errors ERRORS = new Errors(Collections.singletonList(new Errors.Error("code", "message")));
5151

52+
private static final Message NO_MESSAGE = new Message(null);
53+
54+
private static final Message MESSAGE = new Message("response message");
55+
5256
@Test
5357
void createWhenHostIsNullThrowsException() {
5458
assertThatIllegalArgumentException()
55-
.isThrownBy(() -> new DockerEngineException(null, null, 404, null, NO_ERRORS))
56-
.withMessage("host must not be null");
59+
.isThrownBy(() -> new DockerEngineException(null, null, 404, null, NO_ERRORS, NO_MESSAGE))
60+
.withMessage("Host must not be null");
5761
}
5862

5963
@Test
6064
void createWhenUriIsNullThrowsException() {
6165
assertThatIllegalArgumentException()
62-
.isThrownBy(() -> new DockerEngineException(HOST, null, 404, null, NO_ERRORS))
66+
.isThrownBy(() -> new DockerEngineException(HOST, null, 404, null, NO_ERRORS, NO_MESSAGE))
6367
.withMessage("URI must not be null");
6468
}
6569

6670
@Test
6771
void create() {
68-
DockerEngineException exception = new DockerEngineException(HOST, URI, 404, "missing", ERRORS);
72+
DockerEngineException exception = new DockerEngineException(HOST, URI, 404, "missing", ERRORS, MESSAGE);
6973
assertThat(exception.getMessage()).isEqualTo(
70-
"Docker API call to 'docker://localhost/example' failed with status code 404 \"missing\" [code: message]");
74+
"Docker API call to 'docker://localhost/example' failed with status code 404 \"missing\" and message \"response message\" [code: message]");
7175
assertThat(exception.getStatusCode()).isEqualTo(404);
7276
assertThat(exception.getReasonPhrase()).isEqualTo("missing");
7377
assertThat(exception.getErrors()).isSameAs(ERRORS);
78+
assertThat(exception.getResponseMessage()).isSameAs(MESSAGE);
7479
}
7580

7681
@Test
7782
void createWhenReasonPhraseIsNull() {
78-
DockerEngineException exception = new DockerEngineException(HOST, URI, 404, null, ERRORS);
83+
DockerEngineException exception = new DockerEngineException(HOST, URI, 404, null, ERRORS, MESSAGE);
7984
assertThat(exception.getMessage()).isEqualTo(
80-
"Docker API call to 'docker://localhost/example' failed with status code 404 [code: message]");
85+
"Docker API call to 'docker://localhost/example' failed with status code 404 and message \"response message\" [code: message]");
8186
assertThat(exception.getStatusCode()).isEqualTo(404);
8287
assertThat(exception.getReasonPhrase()).isNull();
8388
assertThat(exception.getErrors()).isSameAs(ERRORS);
89+
assertThat(exception.getResponseMessage()).isSameAs(MESSAGE);
8490
}
8591

8692
@Test
8793
void createWhenErrorsIsNull() {
88-
DockerEngineException exception = new DockerEngineException(HOST, URI, 404, "missing", null);
94+
DockerEngineException exception = new DockerEngineException(HOST, URI, 404, "missing", null, MESSAGE);
95+
assertThat(exception.getMessage()).isEqualTo(
96+
"Docker API call to 'docker://localhost/example' failed with status code 404 \"missing\" and message \"response message\"");
8997
assertThat(exception.getErrors()).isNull();
9098
}
9199

92100
@Test
93101
void createWhenErrorsIsEmpty() {
94-
DockerEngineException exception = new DockerEngineException(HOST, URI, 404, "missing", NO_ERRORS);
95-
assertThat(exception.getMessage())
96-
.isEqualTo("Docker API call to 'docker://localhost/example' failed with status code 404 \"missing\"");
102+
DockerEngineException exception = new DockerEngineException(HOST, URI, 404, "missing", NO_ERRORS, MESSAGE);
103+
assertThat(exception.getMessage()).isEqualTo(
104+
"Docker API call to 'docker://localhost/example' failed with status code 404 \"missing\" and message \"response message\"");
97105
assertThat(exception.getStatusCode()).isEqualTo(404);
98106
assertThat(exception.getReasonPhrase()).isEqualTo("missing");
99107
assertThat(exception.getErrors()).isSameAs(NO_ERRORS);
108+
}
109+
110+
@Test
111+
void createWhenMessageIsNull() {
112+
DockerEngineException exception = new DockerEngineException(HOST, URI, 404, "missing", ERRORS, null);
113+
assertThat(exception.getMessage()).isEqualTo(
114+
"Docker API call to 'docker://localhost/example' failed with status code 404 \"missing\" [code: message]");
115+
assertThat(exception.getResponseMessage()).isNull();
116+
}
100117

118+
@Test
119+
void createWhenMessageIsEmpty() {
120+
DockerEngineException exception = new DockerEngineException(HOST, URI, 404, "missing", ERRORS, NO_MESSAGE);
121+
assertThat(exception.getMessage()).isEqualTo(
122+
"Docker API call to 'docker://localhost/example' failed with status code 404 \"missing\" [code: message]");
123+
assertThat(exception.getResponseMessage()).isSameAs(NO_MESSAGE);
101124
}
102125

103126
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/transport/HttpClientTransportTests.java

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,42 @@ void executeWhenResponseIsIn400RangeShouldThrowDockerException() throws IOExcept
181181
given(this.entity.getContent()).willReturn(getClass().getResourceAsStream("errors.json"));
182182
given(this.statusLine.getStatusCode()).willReturn(404);
183183
assertThatExceptionOfType(DockerEngineException.class).isThrownBy(() -> this.http.get(this.uri))
184-
.satisfies((ex) -> assertThat(ex.getErrors()).hasSize(2));
184+
.satisfies((ex) -> {
185+
assertThat(ex.getErrors()).hasSize(2);
186+
assertThat(ex.getResponseMessage()).isNull();
187+
});
185188
}
186189

187190
@Test
188-
void executeWhenResponseIsIn500RangeShouldThrowDockerException() {
191+
void executeWhenResponseIsIn500RangeWithNoContentShouldThrowDockerException() {
189192
given(this.statusLine.getStatusCode()).willReturn(500);
190193
assertThatExceptionOfType(DockerEngineException.class).isThrownBy(() -> this.http.get(this.uri))
191-
.satisfies((ex) -> assertThat(ex.getErrors()).isNull());
194+
.satisfies((ex) -> {
195+
assertThat(ex.getErrors()).isNull();
196+
assertThat(ex.getResponseMessage()).isNull();
197+
});
198+
}
199+
200+
@Test
201+
void executeWhenResponseIsIn500RangeWithMessageShouldThrowDockerException() throws IOException {
202+
given(this.entity.getContent()).willReturn(getClass().getResourceAsStream("message.json"));
203+
given(this.statusLine.getStatusCode()).willReturn(500);
204+
assertThatExceptionOfType(DockerEngineException.class).isThrownBy(() -> this.http.get(this.uri))
205+
.satisfies((ex) -> {
206+
assertThat(ex.getErrors()).isNull();
207+
assertThat(ex.getResponseMessage().getMessage()).contains("test message");
208+
});
209+
}
210+
211+
@Test
212+
void executeWhenResponseIsIn500RangeWithOtherContentShouldThrowDockerException() throws IOException {
213+
given(this.entity.getContent()).willReturn(this.content);
214+
given(this.statusLine.getStatusCode()).willReturn(500);
215+
assertThatExceptionOfType(DockerEngineException.class).isThrownBy(() -> this.http.get(this.uri))
216+
.satisfies((ex) -> {
217+
assertThat(ex.getErrors()).isNull();
218+
assertThat(ex.getResponseMessage()).isNull();
219+
});
192220
}
193221

194222
@Test
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2012-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.buildpack.platform.docker.transport;
18+
19+
import org.junit.jupiter.api.Test;
20+
21+
import org.springframework.boot.buildpack.platform.json.AbstractJsonTests;
22+
23+
import static org.assertj.core.api.Assertions.assertThat;
24+
25+
/**
26+
* Tests for {@link Message}.
27+
*
28+
* @author Scott Frederick
29+
*/
30+
class MessageTests extends AbstractJsonTests {
31+
32+
@Test
33+
void readValueDeserializesJson() throws Exception {
34+
Message message = getObjectMapper().readValue(getContent("message.json"), Message.class);
35+
assertThat(message.getMessage()).isEqualTo("test message");
36+
}
37+
38+
@Test
39+
void toStringHasErrorDetails() throws Exception {
40+
Message errors = getObjectMapper().readValue(getContent("message.json"), Message.class);
41+
assertThat(errors.toString()).isEqualTo("test message");
42+
}
43+
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"message": "test message"
3+
}

0 commit comments

Comments
 (0)