Skip to content

Commit 6fe2446

Browse files
authored
feat(gax-httpjson): add HttpJsonErrorParser utility (#4137)
This PR introduces the `HttpJsonErrorParser`, a shared utility within `gax-httpjson` responsible for extracting and unpacking `com.google.rpc.Status` payloads embedded within HTTP JSON error responses. Specifically, it uses `JsonFormat` and a custom `TypeRegistry` to cleanly unpack `Any` details (such as `google.rpc.ErrorInfo`) into a standard `com.google.api.gax.rpc.ErrorDetails` object. This achieves structural parity with how errors are extracted from gRPC trailers, doing so without consuming the underlying `InputStream` on the wire. #### Context & Future Use This utility is the first building block in a broader initiative to implement production logging across the Java client libraries. In upcoming PRs, this parser will be utilized directly by the `HttpJsonLoggingInterceptor` to safely unpack error metadata (like `reason`, `domain`, and `metadata` from `ErrorInfo`). ### Testing - Added comprehensive unit tests in `HttpJsonErrorParserTest`. - Verified correct extraction of `ErrorInfo` details and handling of empty or malformed JSON payloads.
1 parent 487650e commit 6fe2446

File tree

2 files changed

+260
-0
lines changed

2 files changed

+260
-0
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
31+
package com.google.api.gax.httpjson;
32+
33+
import com.google.api.core.InternalApi;
34+
import com.google.api.gax.rpc.ErrorDetails;
35+
import com.google.gson.JsonElement;
36+
import com.google.gson.JsonObject;
37+
import com.google.gson.JsonParser;
38+
import com.google.gson.JsonSyntaxException;
39+
import com.google.protobuf.InvalidProtocolBufferException;
40+
import com.google.protobuf.TypeRegistry;
41+
import com.google.protobuf.util.JsonFormat;
42+
import com.google.rpc.BadRequest;
43+
import com.google.rpc.DebugInfo;
44+
import com.google.rpc.ErrorInfo;
45+
import com.google.rpc.Help;
46+
import com.google.rpc.LocalizedMessage;
47+
import com.google.rpc.PreconditionFailure;
48+
import com.google.rpc.QuotaFailure;
49+
import com.google.rpc.RequestInfo;
50+
import com.google.rpc.ResourceInfo;
51+
import com.google.rpc.RetryInfo;
52+
import com.google.rpc.Status;
53+
54+
/**
55+
* Utility for parsing Google Cloud error responses from JSON.
56+
*
57+
* <p>This parser extracts {@link ErrorDetails} from a standard Google Cloud error response JSON
58+
* payload as defined in <a
59+
* href="https://google.aip.dev/193#http11-json-representation">AIP-193</a>. The payload typically
60+
* contains a top-level "error" object with a "details" list.
61+
*/
62+
@InternalApi
63+
class HttpJsonErrorParser {
64+
65+
private static final TypeRegistry STANDARD_ERROR_TYPES =
66+
TypeRegistry.newBuilder()
67+
.add(ErrorInfo.getDescriptor())
68+
.add(RetryInfo.getDescriptor())
69+
.add(DebugInfo.getDescriptor())
70+
.add(QuotaFailure.getDescriptor())
71+
.add(PreconditionFailure.getDescriptor())
72+
.add(BadRequest.getDescriptor())
73+
.add(RequestInfo.getDescriptor())
74+
.add(ResourceInfo.getDescriptor())
75+
.add(Help.getDescriptor())
76+
.add(LocalizedMessage.getDescriptor())
77+
.build();
78+
private static final JsonFormat.Parser JSON_PARSER =
79+
JsonFormat.parser().ignoringUnknownFields().usingTypeRegistry(STANDARD_ERROR_TYPES);
80+
81+
/**
82+
* Parses the given JSON error payload into {@link Status}.
83+
*
84+
* @param errorJson The JSON string representing a Google Cloud error response.
85+
* @return A {@link Status} message containing the parsed error information. Returns {@link
86+
* Status#getDefaultInstance()} if the input is null, empty, or invalid.
87+
*/
88+
static Status parseStatus(String errorJson) {
89+
if (errorJson == null || errorJson.isEmpty()) {
90+
return Status.getDefaultInstance();
91+
}
92+
93+
JsonElement jsonElement;
94+
try {
95+
jsonElement = JsonParser.parseString(errorJson);
96+
} catch (JsonSyntaxException e) {
97+
return Status.getDefaultInstance();
98+
}
99+
100+
if (!jsonElement.isJsonObject()) {
101+
return Status.getDefaultInstance();
102+
}
103+
JsonObject root = jsonElement.getAsJsonObject();
104+
if (!root.has("error")) {
105+
return Status.getDefaultInstance();
106+
}
107+
108+
JsonElement errorElement = root.get("error");
109+
if (!errorElement.isJsonObject()) {
110+
return Status.getDefaultInstance();
111+
}
112+
113+
Status.Builder statusBuilder = Status.newBuilder();
114+
try {
115+
JSON_PARSER.merge(errorElement.toString(), statusBuilder);
116+
} catch (InvalidProtocolBufferException e) {
117+
return Status.getDefaultInstance();
118+
}
119+
120+
return statusBuilder.build();
121+
}
122+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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.gax.rpc.ErrorDetails;
35+
import com.google.rpc.ErrorInfo;
36+
import org.junit.jupiter.api.Test;
37+
38+
/** Tests for {@link HttpJsonErrorParser}. */
39+
class HttpJsonErrorParserTest {
40+
41+
@Test
42+
void parseStatus_success() {
43+
String payload =
44+
"{\n"
45+
+ " \"error\": {\n"
46+
+ " \"code\": 401,\n"
47+
+ " \"message\": \"Request is missing required authentication credential.\",\n"
48+
+ " \"status\": \"UNAUTHENTICATED\",\n"
49+
+ " \"details\": [\n"
50+
+ " {\n"
51+
+ " \"@type\": \"type.googleapis.com/google.rpc.ErrorInfo\",\n"
52+
+ " \"reason\": \"SERVICE_DISABLED\",\n"
53+
+ " \"domain\": \"googleapis.com\",\n"
54+
+ " \"metadata\": {\n"
55+
+ " \"service\": \"pubsub.googleapis.com\"\n"
56+
+ " }\n"
57+
+ " }\n"
58+
+ " ]\n"
59+
+ " }\n"
60+
+ "}";
61+
62+
com.google.rpc.Status status = HttpJsonErrorParser.parseStatus(payload);
63+
assertThat(status).isNotNull();
64+
assertThat(status.getCode()).isEqualTo(401);
65+
assertThat(status.getMessage())
66+
.isEqualTo("Request is missing required authentication credential.");
67+
68+
ErrorDetails errorDetails =
69+
ErrorDetails.builder().setRawErrorMessages(status.getDetailsList()).build();
70+
ErrorInfo errorInfo = errorDetails.getErrorInfo();
71+
assertThat(errorInfo).isNotNull();
72+
assertThat(errorInfo.getReason()).isEqualTo("SERVICE_DISABLED");
73+
assertThat(errorInfo.getDomain()).isEqualTo("googleapis.com");
74+
assertThat(errorInfo.getMetadataMap().get("service")).isEqualTo("pubsub.googleapis.com");
75+
}
76+
77+
@Test
78+
void parseStatus_noErrorInfo() {
79+
String payload =
80+
"{\n"
81+
+ " \"error\": {\n"
82+
+ " \"details\": [\n"
83+
+ " {\n"
84+
+ " \"@type\": \"type.googleapis.com/google.rpc.RetryInfo\"\n"
85+
+ " }\n"
86+
+ " ]\n"
87+
+ " }\n"
88+
+ "}";
89+
90+
com.google.rpc.Status status = HttpJsonErrorParser.parseStatus(payload);
91+
assertThat(status).isNotNull();
92+
ErrorDetails errorDetails =
93+
ErrorDetails.builder().setRawErrorMessages(status.getDetailsList()).build();
94+
assertThat(errorDetails.getRetryInfo()).isNotNull();
95+
}
96+
97+
@Test
98+
void parseStatus_emptyPayload() {
99+
assertThat(HttpJsonErrorParser.parseStatus(""))
100+
.isEqualTo(com.google.rpc.Status.getDefaultInstance());
101+
assertThat(HttpJsonErrorParser.parseStatus(null))
102+
.isEqualTo(com.google.rpc.Status.getDefaultInstance());
103+
}
104+
105+
@Test
106+
void parseStatus_invalidJson() {
107+
assertThat(HttpJsonErrorParser.parseStatus("{invalid"))
108+
.isEqualTo(com.google.rpc.Status.getDefaultInstance());
109+
}
110+
111+
@Test
112+
void parseStatus_noErrorObject() {
113+
String payload = "{\"foo\": \"bar\"}";
114+
assertThat(HttpJsonErrorParser.parseStatus(payload))
115+
.isEqualTo(com.google.rpc.Status.getDefaultInstance());
116+
}
117+
118+
@Test
119+
void parseStatus_noDetails() {
120+
String payload = "{\"error\": {}}";
121+
assertThat(HttpJsonErrorParser.parseStatus(payload))
122+
.isEqualTo(com.google.rpc.Status.getDefaultInstance());
123+
}
124+
125+
@Test
126+
void parseStatus_garbageInError() {
127+
String payload = "{\"error\": \"not-an-object\"}";
128+
assertThat(HttpJsonErrorParser.parseStatus(payload))
129+
.isEqualTo(com.google.rpc.Status.getDefaultInstance());
130+
}
131+
132+
@Test
133+
void parseStatus_arrayInError() {
134+
String payload = "{\"error\": []}";
135+
assertThat(HttpJsonErrorParser.parseStatus(payload))
136+
.isEqualTo(com.google.rpc.Status.getDefaultInstance());
137+
}
138+
}

0 commit comments

Comments
 (0)