Skip to content

Commit 6169067

Browse files
authored
feat(gax-httpjson): populate ErrorDetails in HttpJsonApiExceptionFactory (#4145)
1 parent 12920a6 commit 6169067

20 files changed

+350
-17
lines changed

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

Lines changed: 17 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,21 @@ 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+
if (message == null) {
66+
message = throwable.toString();
67+
}
68+
69+
return ApiExceptionFactory.createException(
70+
message, throwable, statusCode, canRetry, errorDetails);
5571
} else if (throwable instanceof HttpJsonStatusRuntimeException) {
5672
HttpJsonStatusRuntimeException e = (HttpJsonStatusRuntimeException) throwable;
5773
StatusCode statusCode = HttpJsonStatusCode.of(e.getStatusCode());
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/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: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,40 +83,50 @@ public static ApiException createException(
8383

8484
public static ApiException createException(
8585
Throwable cause, StatusCode statusCode, boolean retryable, ErrorDetails errorDetails) {
86+
return createException(
87+
cause == null ? null : cause.toString(), cause, statusCode, retryable, errorDetails);
88+
}
89+
90+
public static ApiException createException(
91+
String message,
92+
Throwable cause,
93+
StatusCode statusCode,
94+
boolean retryable,
95+
ErrorDetails errorDetails) {
8696
switch (statusCode.getCode()) {
8797
case CANCELLED:
88-
return new CancelledException(cause, statusCode, retryable, errorDetails);
98+
return new CancelledException(message, cause, statusCode, retryable, errorDetails);
8999
case NOT_FOUND:
90-
return new NotFoundException(cause, statusCode, retryable, errorDetails);
100+
return new NotFoundException(message, cause, statusCode, retryable, errorDetails);
91101
case INVALID_ARGUMENT:
92-
return new InvalidArgumentException(cause, statusCode, retryable, errorDetails);
102+
return new InvalidArgumentException(message, cause, statusCode, retryable, errorDetails);
93103
case DEADLINE_EXCEEDED:
94-
return new DeadlineExceededException(cause, statusCode, retryable, errorDetails);
104+
return new DeadlineExceededException(message, cause, statusCode, retryable, errorDetails);
95105
case ALREADY_EXISTS:
96-
return new AlreadyExistsException(cause, statusCode, retryable, errorDetails);
106+
return new AlreadyExistsException(message, cause, statusCode, retryable, errorDetails);
97107
case PERMISSION_DENIED:
98-
return new PermissionDeniedException(cause, statusCode, retryable, errorDetails);
108+
return new PermissionDeniedException(message, cause, statusCode, retryable, errorDetails);
99109
case RESOURCE_EXHAUSTED:
100-
return new ResourceExhaustedException(cause, statusCode, retryable, errorDetails);
110+
return new ResourceExhaustedException(message, cause, statusCode, retryable, errorDetails);
101111
case FAILED_PRECONDITION:
102-
return new FailedPreconditionException(cause, statusCode, retryable, errorDetails);
112+
return new FailedPreconditionException(message, cause, statusCode, retryable, errorDetails);
103113
case ABORTED:
104-
return new AbortedException(cause, statusCode, retryable, errorDetails);
114+
return new AbortedException(message, cause, statusCode, retryable, errorDetails);
105115
case OUT_OF_RANGE:
106-
return new OutOfRangeException(cause, statusCode, retryable, errorDetails);
116+
return new OutOfRangeException(message, cause, statusCode, retryable, errorDetails);
107117
case UNIMPLEMENTED:
108-
return new UnimplementedException(cause, statusCode, retryable, errorDetails);
118+
return new UnimplementedException(message, cause, statusCode, retryable, errorDetails);
109119
case INTERNAL:
110-
return new InternalException(cause, statusCode, retryable, errorDetails);
120+
return new InternalException(message, cause, statusCode, retryable, errorDetails);
111121
case UNAVAILABLE:
112-
return new UnavailableException(cause, statusCode, retryable, errorDetails);
122+
return new UnavailableException(message, cause, statusCode, retryable, errorDetails);
113123
case DATA_LOSS:
114-
return new DataLossException(cause, statusCode, retryable, errorDetails);
124+
return new DataLossException(message, cause, statusCode, retryable, errorDetails);
115125
case UNAUTHENTICATED:
116-
return new UnauthenticatedException(cause, statusCode, retryable, errorDetails);
126+
return new UnauthenticatedException(message, cause, statusCode, retryable, errorDetails);
117127
case UNKNOWN: // Fall through.
118128
default:
119-
return new UnknownException(cause, statusCode, retryable, errorDetails);
129+
return new UnknownException(message, cause, statusCode, retryable, errorDetails);
120130
}
121131
}
122132
}

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
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,13 @@ public DeadlineExceededException(
4949
Throwable cause, StatusCode statusCode, boolean retryable, ErrorDetails errorDetails) {
5050
super(cause, statusCode, retryable, errorDetails);
5151
}
52+
53+
public DeadlineExceededException(
54+
String message,
55+
Throwable cause,
56+
StatusCode statusCode,
57+
boolean retryable,
58+
ErrorDetails errorDetails) {
59+
super(message, cause, statusCode, retryable, errorDetails);
60+
}
5261
}

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

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

0 commit comments

Comments
 (0)