Skip to content

Commit 573da98

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

22 files changed

Lines changed: 397 additions & 18 deletions

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

Lines changed: 13 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,17 @@ 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+
58+
if (!status.getMessage().isEmpty()) {
59+
message = status.getMessage();
60+
}
61+
62+
ErrorDetails errorDetails =
63+
ErrorDetails.builder().setRawErrorMessages(status.getDetailsList()).build();
64+
65+
return ApiExceptionFactory.createException(
66+
message, throwable, statusCode, canRetry, errorDetails);
5567
} else if (throwable instanceof HttpJsonStatusRuntimeException) {
5668
HttpJsonStatusRuntimeException e = (HttpJsonStatusRuntimeException) throwable;
5769
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: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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_withAllFieldsPresent() {
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(403, "Forbidden", 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+
// The status code should be derived from the JSON code (7), ignoring HTTP code (403).
74+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.PERMISSION_DENIED);
75+
assertThat(apiException.isRetryable()).isFalse();
76+
// The message should be overridden by the JSON message
77+
assertThat(apiException.getMessage()).contains("The caller does not have permission");
78+
79+
ErrorDetails details = apiException.getErrorDetails();
80+
assertThat(details).isNotNull();
81+
assertThat(details.getErrorInfo()).isNotNull();
82+
assertThat(details.getErrorInfo().getReason()).isEqualTo("SERVICE_DISABLED");
83+
assertThat(details.getErrorInfo().getDomain()).isEqualTo("googleapis.com");
84+
assertThat(details.getErrorInfo().getMetadataMap().get("service"))
85+
.isEqualTo("pubsub.googleapis.com");
86+
}
87+
88+
@Test
89+
void testCreate_withOkStatusNoMessageNoDetails() {
90+
String payload = "{\n \"error\": {\n \"code\": 0\n }\n}";
91+
92+
HttpResponseException exception =
93+
new HttpResponseException.Builder(403, "Forbidden", new HttpHeaders())
94+
.setContent(payload)
95+
.build();
96+
97+
HttpJsonApiExceptionFactory factory =
98+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
99+
ApiException apiException = factory.create(exception);
100+
101+
// Because code is 0 (OK), it falls back to the HTTP status code (403 -> PERMISSION_DENIED).
102+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.PERMISSION_DENIED);
103+
assertThat(apiException.isRetryable()).isFalse();
104+
// Because there is no message in the payload, it falls back to the HTTP status message.
105+
assertThat(apiException.getMessage()).contains("Forbidden");
106+
// Details are unconditionally built, but empty.
107+
assertThat(apiException.getErrorDetails()).isNotNull();
108+
assertThat(apiException.getErrorDetails().getErrorInfo()).isNull();
109+
}
110+
111+
@Test
112+
void testCreate_withMessageOverridesHttpStatusMessage() {
113+
String payload =
114+
"{\n"
115+
+ " \"error\": {\n"
116+
+ " \"message\": \"Custom detailed error message from server\"\n"
117+
+ " }\n"
118+
+ "}";
119+
120+
// Transport layer returned generic "Bad Request" phrase
121+
HttpResponseException exception =
122+
new HttpResponseException.Builder(400, "Bad Request", new HttpHeaders())
123+
.setContent(payload)
124+
.build();
125+
126+
HttpJsonApiExceptionFactory factory =
127+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
128+
ApiException apiException = factory.create(exception);
129+
130+
assertThat(apiException.getMessage()).contains("Custom detailed error message from server");
131+
assertThat(apiException.getMessage()).doesNotContain("Bad Request");
132+
}
133+
134+
@Test
135+
void testCreate_withoutErrorDetails() {
136+
HttpResponseException exception =
137+
new HttpResponseException.Builder(503, "Service Unavailable", new HttpHeaders())
138+
.setContent("plain text error")
139+
.build();
140+
141+
HttpJsonApiExceptionFactory factory =
142+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
143+
ApiException apiException = factory.create(exception);
144+
145+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.UNAVAILABLE);
146+
assertThat(apiException.isRetryable()).isTrue();
147+
// Plain text error parsing will still generate an empty ErrorDetails
148+
assertThat(apiException.getErrorDetails()).isNotNull();
149+
assertThat(apiException.getErrorDetails().getErrorInfo()).isNull();
150+
}
151+
}

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
}

gax-java/gax/src/main/java/com/google/api/gax/rpc/AbortedException.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,13 @@ public AbortedException(
4747
Throwable cause, StatusCode statusCode, boolean retryable, ErrorDetails errorDetails) {
4848
super(cause, statusCode, retryable, errorDetails);
4949
}
50+
51+
public AbortedException(
52+
String message,
53+
Throwable cause,
54+
StatusCode statusCode,
55+
boolean retryable,
56+
ErrorDetails errorDetails) {
57+
super(message, cause, statusCode, retryable, errorDetails);
58+
}
5059
}

gax-java/gax/src/main/java/com/google/api/gax/rpc/AlreadyExistsException.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,13 @@ public AlreadyExistsException(
4747
Throwable cause, StatusCode statusCode, boolean retryable, ErrorDetails errorDetails) {
4848
super(cause, statusCode, retryable, errorDetails);
4949
}
50+
51+
public AlreadyExistsException(
52+
String message,
53+
Throwable cause,
54+
StatusCode statusCode,
55+
boolean retryable,
56+
ErrorDetails errorDetails) {
57+
super(message, cause, statusCode, retryable, errorDetails);
58+
}
5059
}

gax-java/gax/src/main/java/com/google/api/gax/rpc/ApiException.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ public ApiException(
6060
this.errorDetails = errorDetails;
6161
}
6262

63+
public ApiException(
64+
String message,
65+
Throwable cause,
66+
StatusCode statusCode,
67+
boolean retryable,
68+
ErrorDetails errorDetails) {
69+
super(message, cause);
70+
this.statusCode = Preconditions.checkNotNull(statusCode);
71+
this.retryable = retryable;
72+
this.errorDetails = errorDetails;
73+
}
74+
6375
/** Returns whether the failed request can be retried. */
6476
public boolean isRetryable() {
6577
return retryable;

gax-java/gax/src/main/java/com/google/api/gax/rpc/ApiExceptionFactory.java

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,40 +83,49 @@ public static ApiException createException(
8383

8484
public static ApiException createException(
8585
Throwable cause, StatusCode statusCode, boolean retryable, ErrorDetails errorDetails) {
86+
return createException(null, cause, statusCode, retryable, errorDetails);
87+
}
88+
89+
public static ApiException createException(
90+
String message,
91+
Throwable cause,
92+
StatusCode statusCode,
93+
boolean retryable,
94+
ErrorDetails errorDetails) {
8695
switch (statusCode.getCode()) {
8796
case CANCELLED:
88-
return new CancelledException(cause, statusCode, retryable, errorDetails);
97+
return new CancelledException(message, cause, statusCode, retryable, errorDetails);
8998
case NOT_FOUND:
90-
return new NotFoundException(cause, statusCode, retryable, errorDetails);
99+
return new NotFoundException(message, cause, statusCode, retryable, errorDetails);
91100
case INVALID_ARGUMENT:
92-
return new InvalidArgumentException(cause, statusCode, retryable, errorDetails);
101+
return new InvalidArgumentException(message, cause, statusCode, retryable, errorDetails);
93102
case DEADLINE_EXCEEDED:
94-
return new DeadlineExceededException(cause, statusCode, retryable, errorDetails);
103+
return new DeadlineExceededException(message, cause, statusCode, retryable, errorDetails);
95104
case ALREADY_EXISTS:
96-
return new AlreadyExistsException(cause, statusCode, retryable, errorDetails);
105+
return new AlreadyExistsException(message, cause, statusCode, retryable, errorDetails);
97106
case PERMISSION_DENIED:
98-
return new PermissionDeniedException(cause, statusCode, retryable, errorDetails);
107+
return new PermissionDeniedException(message, cause, statusCode, retryable, errorDetails);
99108
case RESOURCE_EXHAUSTED:
100-
return new ResourceExhaustedException(cause, statusCode, retryable, errorDetails);
109+
return new ResourceExhaustedException(message, cause, statusCode, retryable, errorDetails);
101110
case FAILED_PRECONDITION:
102-
return new FailedPreconditionException(cause, statusCode, retryable, errorDetails);
111+
return new FailedPreconditionException(message, cause, statusCode, retryable, errorDetails);
103112
case ABORTED:
104-
return new AbortedException(cause, statusCode, retryable, errorDetails);
113+
return new AbortedException(message, cause, statusCode, retryable, errorDetails);
105114
case OUT_OF_RANGE:
106-
return new OutOfRangeException(cause, statusCode, retryable, errorDetails);
115+
return new OutOfRangeException(message, cause, statusCode, retryable, errorDetails);
107116
case UNIMPLEMENTED:
108-
return new UnimplementedException(cause, statusCode, retryable, errorDetails);
117+
return new UnimplementedException(message, cause, statusCode, retryable, errorDetails);
109118
case INTERNAL:
110-
return new InternalException(cause, statusCode, retryable, errorDetails);
119+
return new InternalException(message, cause, statusCode, retryable, errorDetails);
111120
case UNAVAILABLE:
112-
return new UnavailableException(cause, statusCode, retryable, errorDetails);
121+
return new UnavailableException(message, cause, statusCode, retryable, errorDetails);
113122
case DATA_LOSS:
114-
return new DataLossException(cause, statusCode, retryable, errorDetails);
123+
return new DataLossException(message, cause, statusCode, retryable, errorDetails);
115124
case UNAUTHENTICATED:
116-
return new UnauthenticatedException(cause, statusCode, retryable, errorDetails);
125+
return new UnauthenticatedException(message, cause, statusCode, retryable, errorDetails);
117126
case UNKNOWN: // Fall through.
118127
default:
119-
return new UnknownException(cause, statusCode, retryable, errorDetails);
128+
return new UnknownException(message, cause, statusCode, retryable, errorDetails);
120129
}
121130
}
122131
}

gax-java/gax/src/main/java/com/google/api/gax/rpc/CancelledException.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,13 @@ public CancelledException(
4444
Throwable cause, StatusCode statusCode, boolean retryable, ErrorDetails errorDetails) {
4545
super(cause, statusCode, retryable, errorDetails);
4646
}
47+
48+
public CancelledException(
49+
String message,
50+
Throwable cause,
51+
StatusCode statusCode,
52+
boolean retryable,
53+
ErrorDetails errorDetails) {
54+
super(message, cause, statusCode, retryable, errorDetails);
55+
}
4756
}

gax-java/gax/src/main/java/com/google/api/gax/rpc/DataLossException.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,13 @@ public DataLossException(
4444
Throwable cause, StatusCode statusCode, boolean retryable, ErrorDetails errorDetails) {
4545
super(cause, statusCode, retryable, errorDetails);
4646
}
47+
48+
public DataLossException(
49+
String message,
50+
Throwable cause,
51+
StatusCode statusCode,
52+
boolean retryable,
53+
ErrorDetails errorDetails) {
54+
super(message, cause, statusCode, retryable, errorDetails);
55+
}
4756
}

0 commit comments

Comments
 (0)