Skip to content

Commit d03cdc3

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

4 files changed

Lines changed: 279 additions & 2 deletions

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());

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,21 @@ static Status parseStatus(String errorJson) {
117117
return Status.getDefaultInstance();
118118
}
119119

120+
// AIP-193 specifies that the 'status' field contains the gRPC status as a string.
121+
// The 'code' field typically contains the HTTP status code, which JsonFormat natively
122+
// maps into the Builder's 'code' field. To ensure the resulting com.google.rpc.Status
123+
// has the correct gRPC integer code, we override it using the 'status' string if present.
124+
if (errorElement.getAsJsonObject().has("status")) {
125+
try {
126+
String statusStr = errorElement.getAsJsonObject().get("status").getAsString();
127+
com.google.rpc.Code rpcCode = com.google.rpc.Code.valueOf(statusStr);
128+
statusBuilder.setCode(rpcCode.getNumber());
129+
} catch (IllegalArgumentException | UnsupportedOperationException e) {
130+
// Ignore if the status string doesn't match a known google.rpc.Code enum value,
131+
// or if it isn't a string.
132+
}
133+
}
134+
120135
return statusBuilder.build();
121136
}
122137
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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_withUnknownInvalidGrpcCode() {
138+
String payload =
139+
"{\n"
140+
+ " \"error\": {\n"
141+
+ " \"code\": 9999\n" // Invalid/Unknown gRPC code
142+
+ " }\n"
143+
+ "}";
144+
145+
HttpResponseException exception =
146+
new HttpResponseException.Builder(403, "Forbidden", new HttpHeaders())
147+
.setContent(payload)
148+
.build();
149+
150+
HttpJsonApiExceptionFactory factory =
151+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
152+
ApiException apiException = factory.create(exception);
153+
154+
// Because the code 9999 doesn't map to a valid com.google.rpc.Code, it safely
155+
// falls back to the HTTP status code without throwing a NullPointerException.
156+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.PERMISSION_DENIED);
157+
assertThat(apiException.isRetryable()).isFalse();
158+
assertThat(apiException.getErrorDetails()).isNull();
159+
}
160+
161+
@Test
162+
void testCreate_withMessageOverridesHttpStatusMessage() {
163+
String payload =
164+
"{\n"
165+
+ " \"error\": {\n"
166+
+ " \"message\": \"Custom detailed error message from server\"\n"
167+
+ " }\n"
168+
+ "}";
169+
170+
// Transport layer returned generic "Bad Request" phrase
171+
HttpResponseException exception =
172+
new HttpResponseException.Builder(400, "Bad Request", new HttpHeaders())
173+
.setContent(payload)
174+
.build();
175+
176+
HttpJsonApiExceptionFactory factory =
177+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
178+
ApiException apiException = factory.create(exception);
179+
180+
assertThat(apiException.getMessage()).contains("Custom detailed error message from server");
181+
assertThat(apiException.getMessage()).doesNotContain("Bad Request");
182+
}
183+
184+
@Test
185+
void testCreate_withoutErrorDetails() {
186+
HttpResponseException exception =
187+
new HttpResponseException.Builder(503, "Service Unavailable", new HttpHeaders())
188+
.setContent("plain text error")
189+
.build();
190+
191+
HttpJsonApiExceptionFactory factory =
192+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
193+
ApiException apiException = factory.create(exception);
194+
195+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.UNAVAILABLE);
196+
assertThat(apiException.isRetryable()).isTrue();
197+
assertThat(apiException.getErrorDetails()).isNull();
198+
}
199+
}

gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonErrorParserTest.java

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ void parseStatus_success() {
6161

6262
com.google.rpc.Status status = HttpJsonErrorParser.parseStatus(payload);
6363
assertThat(status).isNotNull();
64-
assertThat(status.getCode()).isEqualTo(401);
64+
assertThat(status.getCode()).isEqualTo(16);
6565
assertThat(status.getMessage())
6666
.isEqualTo("Request is missing required authentication credential.");
6767

@@ -135,4 +135,40 @@ void parseStatus_arrayInError() {
135135
assertThat(HttpJsonErrorParser.parseStatus(payload))
136136
.isEqualTo(com.google.rpc.Status.getDefaultInstance());
137137
}
138+
139+
@Test
140+
void parseStatus_withHttpCodeAndGrpcStatusString() {
141+
// AIP-193 standard JSON mapping typically includes the HTTP code in "code"
142+
// and the gRPC status string in "status". Let's verify what JsonFormat actually extracts
143+
// to the `com.google.rpc.Status` proto when both are present.
144+
String payload =
145+
"{\n"
146+
+ " \"error\": {\n"
147+
+ " \"code\": 403,\n"
148+
+ " \"status\": \"PERMISSION_DENIED\",\n"
149+
+ " \"message\": \"The caller does not have permission\"\n"
150+
+ " }\n"
151+
+ "}";
152+
153+
com.google.rpc.Status status = HttpJsonErrorParser.parseStatus(payload);
154+
155+
// In Protobuf, com.google.rpc.Status ONLY has `int32 code = 1;` and `string message = 2;`
156+
// It does NOT have a `status` field. Because we use `.ignoringUnknownFields()` in the parser,
157+
// the "status": "PERMISSION_DENIED" string is completely thrown away natively.
158+
// However, our parser manually intercepts the 'status' string to override the gRPC integer.
159+
// So we expect 7 (PERMISSION_DENIED), not 403!
160+
assertThat(status.getCode()).isEqualTo(7);
161+
assertThat(status.getMessage()).isEqualTo("The caller does not have permission");
162+
}
163+
164+
@Test
165+
void parseStatus_withOnlyStatusString() {
166+
String payload = "{\n" + " \"error\": {\n" + " \"status\": \"NOT_FOUND\"\n" + " }\n" + "}";
167+
168+
com.google.rpc.Status status = HttpJsonErrorParser.parseStatus(payload);
169+
170+
// Because "code" is missing, JsonFormat sets it to 0 (OK). But our manual override
171+
// sees "status": "NOT_FOUND" and correctly maps it to 5.
172+
assertThat(status.getCode()).isEqualTo(5);
173+
}
138174
}

0 commit comments

Comments
 (0)