Skip to content

Commit e522952

Browse files
committed
feat(gax-httpjson): populate ErrorDetails in HttpJsonApiExceptionFactory
1 parent a1b7565 commit e522952

2 files changed

Lines changed: 201 additions & 0 deletions

File tree

gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@
3232
import com.google.api.client.http.HttpResponseException;
3333
import com.google.api.gax.rpc.ApiException;
3434
import com.google.api.gax.rpc.ApiExceptionFactory;
35+
import com.google.api.gax.rpc.ErrorDetails;
3536
import com.google.api.gax.rpc.StatusCode;
3637
import com.google.api.gax.rpc.StatusCode.Code;
3738
import com.google.common.collect.ImmutableSet;
39+
import com.google.rpc.Status;
3840
import java.util.Set;
3941
import java.util.concurrent.CancellationException;
4042

@@ -51,6 +53,27 @@ ApiException create(Throwable throwable) {
5153
StatusCode statusCode = HttpJsonStatusCode.of(e.getStatusCode());
5254
boolean canRetry = retryableCodes.contains(statusCode.getCode());
5355
String message = e.getStatusMessage();
56+
Status status = HttpJsonErrorParser.parseStatus(e.getContent());
57+
58+
if (status.getCode() > 0) {
59+
statusCode = HttpJsonStatusCode.of(com.google.rpc.Code.forNumber(status.getCode()));
60+
}
61+
62+
if (status.getMessage() != null && !status.getMessage().isEmpty()) {
63+
message = status.getMessage();
64+
throwable =
65+
new HttpResponseException.Builder(
66+
e.getStatusCode(), e.getStatusMessage(), e.getHeaders())
67+
.setContent(e.getContent())
68+
.setMessage(message)
69+
.build();
70+
}
71+
72+
if (status.getDetailsCount() > 0) {
73+
ErrorDetails errorDetails =
74+
ErrorDetails.builder().setRawErrorMessages(status.getDetailsList()).build();
75+
return ApiExceptionFactory.createException(throwable, statusCode, canRetry, errorDetails);
76+
}
5477
return createApiException(throwable, statusCode, message, canRetry);
5578
} else if (throwable instanceof HttpJsonStatusRuntimeException) {
5679
HttpJsonStatusRuntimeException e = (HttpJsonStatusRuntimeException) throwable;
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package com.google.api.gax.httpjson;
31+
32+
import static com.google.common.truth.Truth.assertThat;
33+
34+
import com.google.api.client.http.HttpHeaders;
35+
import com.google.api.client.http.HttpResponseException;
36+
import com.google.api.gax.rpc.ApiException;
37+
import com.google.api.gax.rpc.ErrorDetails;
38+
import com.google.api.gax.rpc.StatusCode.Code;
39+
import com.google.common.collect.ImmutableSet;
40+
import org.junit.jupiter.api.Test;
41+
42+
class HttpJsonApiExceptionFactoryTest {
43+
44+
@Test
45+
void testCreate_withErrorDetails() {
46+
String payload =
47+
"{\n"
48+
+ " \"error\": {\n"
49+
+ " \"code\": 7,\n"
50+
+ " \"message\": \"The caller does not have permission\",\n"
51+
+ " \"details\": [\n"
52+
+ " {\n"
53+
+ " \"@type\": \"type.googleapis.com/google.rpc.ErrorInfo\",\n"
54+
+ " \"reason\": \"SERVICE_DISABLED\",\n"
55+
+ " \"domain\": \"googleapis.com\",\n"
56+
+ " \"metadata\": {\n"
57+
+ " \"service\": \"pubsub.googleapis.com\"\n"
58+
+ " }\n"
59+
+ " }\n"
60+
+ " ]\n"
61+
+ " }\n"
62+
+ "}";
63+
64+
HttpResponseException exception =
65+
new HttpResponseException.Builder(400, "Bad Request", new HttpHeaders())
66+
.setContent(payload)
67+
.build();
68+
69+
HttpJsonApiExceptionFactory factory =
70+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
71+
ApiException apiException = factory.create(exception);
72+
73+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.PERMISSION_DENIED);
74+
assertThat(apiException.isRetryable()).isFalse();
75+
assertThat(apiException.getMessage())
76+
.contains("The caller does not have permission");
77+
78+
ErrorDetails details = apiException.getErrorDetails();
79+
assertThat(details).isNotNull();
80+
assertThat(details.getErrorInfo()).isNotNull();
81+
assertThat(details.getErrorInfo().getReason()).isEqualTo("SERVICE_DISABLED");
82+
assertThat(details.getErrorInfo().getDomain()).isEqualTo("googleapis.com");
83+
assertThat(details.getErrorInfo().getMetadataMap().get("service"))
84+
.isEqualTo("pubsub.googleapis.com");
85+
}
86+
87+
@Test
88+
void testCreate_withOkStatusNoMessageNoDetails() {
89+
String payload =
90+
"{\n"
91+
+ " \"error\": {\n"
92+
+ " \"code\": 0\n"
93+
+ " }\n"
94+
+ "}";
95+
96+
HttpResponseException exception =
97+
new HttpResponseException.Builder(403, "Forbidden", new HttpHeaders())
98+
.setContent(payload)
99+
.build();
100+
101+
HttpJsonApiExceptionFactory factory =
102+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
103+
ApiException apiException = factory.create(exception);
104+
105+
// Because code is 0 (OK), it falls back to the HTTP status code (403 -> PERMISSION_DENIED).
106+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.PERMISSION_DENIED);
107+
assertThat(apiException.isRetryable()).isFalse();
108+
// Because there is no message in the payload, it falls back to the HTTP status message.
109+
assertThat(apiException.getMessage()).contains("Forbidden");
110+
// Because there are no details, ErrorDetails is null.
111+
assertThat(apiException.getErrorDetails()).isNull();
112+
}
113+
114+
@Test
115+
void testCreate_withCodeOverridesHttpStatusCode() {
116+
String payload =
117+
"{\n"
118+
+ " \"error\": {\n"
119+
+ " \"code\": 5\n" // 5 is NOT_FOUND
120+
+ " }\n"
121+
+ "}";
122+
123+
// Transport layer returned 400 Bad Request
124+
HttpResponseException exception =
125+
new HttpResponseException.Builder(400, "Bad Request", new HttpHeaders())
126+
.setContent(payload)
127+
.build();
128+
129+
HttpJsonApiExceptionFactory factory =
130+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.NOT_FOUND));
131+
ApiException apiException = factory.create(exception);
132+
133+
// The gRPC code in the JSON payload (5 -> NOT_FOUND) overrides the HTTP status (400 -> INVALID_ARGUMENT)
134+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.NOT_FOUND);
135+
// But retryability is strictly tied to the transport-level HTTP status (400 -> INVALID_ARGUMENT),
136+
// which is not in our retryable set.
137+
assertThat(apiException.isRetryable()).isFalse();
138+
}
139+
140+
@Test
141+
void testCreate_withMessageOverridesHttpStatusMessage() {
142+
String payload =
143+
"{\n"
144+
+ " \"error\": {\n"
145+
+ " \"message\": \"Custom detailed error message from server\"\n"
146+
+ " }\n"
147+
+ "}";
148+
149+
// Transport layer returned generic "Bad Request" phrase
150+
HttpResponseException exception =
151+
new HttpResponseException.Builder(400, "Bad Request", new HttpHeaders())
152+
.setContent(payload)
153+
.build();
154+
155+
HttpJsonApiExceptionFactory factory =
156+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
157+
ApiException apiException = factory.create(exception);
158+
159+
assertThat(apiException.getMessage()).contains("Custom detailed error message from server");
160+
assertThat(apiException.getMessage()).doesNotContain("Bad Request");
161+
}
162+
163+
@Test
164+
void testCreate_withoutErrorDetails() {
165+
HttpResponseException exception =
166+
new HttpResponseException.Builder(503, "Service Unavailable", new HttpHeaders())
167+
.setContent("plain text error")
168+
.build();
169+
170+
HttpJsonApiExceptionFactory factory =
171+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
172+
ApiException apiException = factory.create(exception);
173+
174+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.UNAVAILABLE);
175+
assertThat(apiException.isRetryable()).isTrue();
176+
assertThat(apiException.getErrorDetails()).isNull();
177+
}
178+
}

0 commit comments

Comments
 (0)