Skip to content

Commit 466bba4

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

2 files changed

Lines changed: 202 additions & 1 deletion

File tree

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

Lines changed: 28 additions & 1 deletion
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,7 +53,32 @@ ApiException create(Throwable throwable) {
5153
StatusCode statusCode = HttpJsonStatusCode.of(e.getStatusCode());
5254
boolean canRetry = retryableCodes.contains(statusCode.getCode());
5355
String message = e.getStatusMessage();
54-
return createApiException(throwable, statusCode, message, canRetry);
56+
Status status = HttpJsonErrorParser.parseStatus(e.getContent());
57+
Throwable cause = throwable;
58+
59+
if (status.getCode() > 0) {
60+
com.google.rpc.Code rpcCode = com.google.rpc.Code.forNumber(status.getCode());
61+
if (rpcCode != null) {
62+
statusCode = HttpJsonStatusCode.of(rpcCode);
63+
}
64+
}
65+
66+
if (!status.getMessage().isEmpty()) {
67+
message = status.getMessage();
68+
cause =
69+
new HttpResponseException.Builder(
70+
e.getStatusCode(), e.getStatusMessage(), e.getHeaders())
71+
.setContent(e.getContent())
72+
.setMessage(message)
73+
.build();
74+
}
75+
76+
if (status.getDetailsCount() > 0) {
77+
ErrorDetails errorDetails =
78+
ErrorDetails.builder().setRawErrorMessages(status.getDetailsList()).build();
79+
return ApiExceptionFactory.createException(cause, statusCode, canRetry, errorDetails);
80+
}
81+
return createApiException(cause, statusCode, message, canRetry);
5582
} else if (throwable instanceof HttpJsonStatusRuntimeException) {
5683
HttpJsonStatusRuntimeException e = (HttpJsonStatusRuntimeException) throwable;
5784
StatusCode statusCode = HttpJsonStatusCode.of(e.getStatusCode());
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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()).contains("The caller does not have permission");
76+
77+
ErrorDetails details = apiException.getErrorDetails();
78+
assertThat(details).isNotNull();
79+
assertThat(details.getErrorInfo()).isNotNull();
80+
assertThat(details.getErrorInfo().getReason()).isEqualTo("SERVICE_DISABLED");
81+
assertThat(details.getErrorInfo().getDomain()).isEqualTo("googleapis.com");
82+
assertThat(details.getErrorInfo().getMetadataMap().get("service"))
83+
.isEqualTo("pubsub.googleapis.com");
84+
}
85+
86+
@Test
87+
void testCreate_withOkStatusNoMessageNoDetails() {
88+
String payload = "{\n" + " \"error\": {\n" + " \"code\": 0\n" + " }\n" + "}";
89+
90+
HttpResponseException exception =
91+
new HttpResponseException.Builder(403, "Forbidden", new HttpHeaders())
92+
.setContent(payload)
93+
.build();
94+
95+
HttpJsonApiExceptionFactory factory =
96+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
97+
ApiException apiException = factory.create(exception);
98+
99+
// Because code is 0 (OK), it falls back to the HTTP status code (403 -> PERMISSION_DENIED).
100+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.PERMISSION_DENIED);
101+
assertThat(apiException.isRetryable()).isFalse();
102+
// Because there is no message in the payload, it falls back to the HTTP status message.
103+
assertThat(apiException.getMessage()).contains("Forbidden");
104+
// Because there are no details, ErrorDetails is null.
105+
assertThat(apiException.getErrorDetails()).isNull();
106+
}
107+
108+
@Test
109+
void testCreate_withCodeOverridesHttpStatusCode() {
110+
String payload =
111+
"{\n"
112+
+ " \"error\": {\n"
113+
+ " \"code\": 5\n" // 5 is NOT_FOUND
114+
+ " }\n"
115+
+ "}";
116+
117+
// Transport layer returned 400 Bad Request
118+
HttpResponseException exception =
119+
new HttpResponseException.Builder(400, "Bad Request", new HttpHeaders())
120+
.setContent(payload)
121+
.build();
122+
123+
HttpJsonApiExceptionFactory factory =
124+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.NOT_FOUND));
125+
ApiException apiException = factory.create(exception);
126+
127+
// The gRPC code in the JSON payload (5 -> NOT_FOUND) overrides the HTTP status (400 ->
128+
// INVALID_ARGUMENT)
129+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.NOT_FOUND);
130+
// But retryability is strictly tied to the transport-level HTTP status (400 ->
131+
// INVALID_ARGUMENT),
132+
// which is not in our retryable set.
133+
assertThat(apiException.isRetryable()).isFalse();
134+
}
135+
136+
@Test
137+
void testCreate_withMessageOverridesHttpStatusMessage() {
138+
String payload =
139+
"{\n"
140+
+ " \"error\": {\n"
141+
+ " \"message\": \"Custom detailed error message from server\"\n"
142+
+ " }\n"
143+
+ "}";
144+
145+
// Transport layer returned generic "Bad Request" phrase
146+
HttpResponseException exception =
147+
new HttpResponseException.Builder(400, "Bad Request", new HttpHeaders())
148+
.setContent(payload)
149+
.build();
150+
151+
HttpJsonApiExceptionFactory factory =
152+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
153+
ApiException apiException = factory.create(exception);
154+
155+
assertThat(apiException.getMessage()).contains("Custom detailed error message from server");
156+
assertThat(apiException.getMessage()).doesNotContain("Bad Request");
157+
}
158+
159+
@Test
160+
void testCreate_withoutErrorDetails() {
161+
HttpResponseException exception =
162+
new HttpResponseException.Builder(503, "Service Unavailable", new HttpHeaders())
163+
.setContent("plain text error")
164+
.build();
165+
166+
HttpJsonApiExceptionFactory factory =
167+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
168+
ApiException apiException = factory.create(exception);
169+
170+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.UNAVAILABLE);
171+
assertThat(apiException.isRetryable()).isTrue();
172+
assertThat(apiException.getErrorDetails()).isNull();
173+
}
174+
}

0 commit comments

Comments
 (0)