Skip to content

Commit 865a15b

Browse files
authored
fix(gax): propagate structured LRO error details to ApiException (#14022)
### Description: When a Long-Running Operation (LRO) completes with a failure, structured error details inside the operation's error payload were previously dropped. This made it impossible for client applications to access fine-grained provider error messages (such as quota or usage violations) via the public `ApiException.getErrorDetails()` API. This PR implements **Phase 1** of the LRO error propagation design by establishing the transport-agnostic mechanism to propagate structured LRO error details in GAX for both gRPC and HTTP/JSON (REST) transports. Design doc: [go/sdk:java-lro-error-details](http://goto.google.com/sdk:java-lro-error-details) ### Key Changes: 1. **Core GAX Interfaces**: - Added support to fetch structured error details from the LRO operation snapshots. 2. **gRPC Transport**: - Implemented error details extraction from LRO operation error payloads (only populated if structured error details are present). - Updated the gRPC response transformer to pass these error details when converting failed operations into exception objects. - Added unit test validation checking details propagation for LRO failures over gRPC. 3. **HTTP/JSON (REST) Transport**: - Implemented error details extraction and storage in REST LRO operation snapshots (only populated if structured error details are present). - Updated the REST response transformer to fetch and pass the snapshot's error details. - Added unit test validation checking details propagation for LRO failures over HTTP/JSON. 5. **Integration & Unit Testing**: - Added a Showcase integration test verifying successful end-to-end propagation of error details over gRPC. - **Note on HTTP/JSON Integration Test**: Deferring the end-to-end Showcase integration test for HTTP/JSON LROs to **Phase 2** because HTTP/JSON relies on a `TypeRegistry` in generated stubs to parse custom/standard payload types packed in `Any` details, the test client fails to deserialize these details. Once we roll out the generator changes in Phase 2 to automatically add error details types to generated stub registries, the Showcase REST integration test will pass out-of-the-box. We have added unit tests to cover the HTTP/JSON LRO response parsing pathway in the interim. ### Testing: - Verified that all gRPC and HTTP/JSON transformer unit tests pass successfully: `mvn test -pl sdk-platform-java/gax-java/gax-grpc,sdk-platform-java/gax-java/gax-httpjson -Dtest=ProtoOperationTransformersTest` - Verified that the Showcase LRO integration test passes successfully: `mvn test -pl java-showcase/gapic-showcase -Dtest=ITLongRunningOperation`
1 parent 66c9920 commit 865a15b

9 files changed

Lines changed: 169 additions & 12 deletions

File tree

java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITLongRunningOperation.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,19 @@
2020

2121
import com.google.api.gax.longrunning.OperationFuture;
2222
import com.google.api.gax.retrying.RetrySettings;
23+
import com.google.api.gax.rpc.ApiException;
24+
import com.google.protobuf.Any;
2325
import com.google.protobuf.Timestamp;
26+
import com.google.rpc.Code;
27+
import com.google.rpc.ErrorInfo;
28+
import com.google.rpc.Status;
2429
import com.google.showcase.v1beta1.EchoClient;
2530
import com.google.showcase.v1beta1.WaitMetadata;
2631
import com.google.showcase.v1beta1.WaitRequest;
2732
import com.google.showcase.v1beta1.WaitResponse;
2833
import com.google.showcase.v1beta1.it.util.TestClientInitializer;
2934
import java.util.concurrent.CancellationException;
35+
import java.util.concurrent.ExecutionException;
3036
import java.util.concurrent.TimeUnit;
3137
import org.junit.jupiter.api.Test;
3238
import org.threeten.bp.Duration;
@@ -193,4 +199,33 @@ void testHttpJson_LROUnsuccessfulResponse_exceedsTotalTimeout_throwsDeadlineExce
193199
TestClientInitializer.AWAIT_TERMINATION_SECONDS, TimeUnit.SECONDS);
194200
}
195201
}
202+
203+
@Test
204+
void testGRPC_LROErrorResponse_propagatesErrorDetails() throws Exception {
205+
EchoClient grpcClient = TestClientInitializer.createGrpcEchoClient();
206+
try {
207+
ErrorInfo errorInfo =
208+
ErrorInfo.newBuilder().setReason("TEST_REASON").setDomain("googleapis.com").build();
209+
Status status =
210+
Status.newBuilder()
211+
.setCode(Code.ALREADY_EXISTS_VALUE)
212+
.setMessage("The resource already exists")
213+
.addDetails(Any.pack(errorInfo))
214+
.build();
215+
WaitRequest waitRequest = WaitRequest.newBuilder().setError(status).build();
216+
OperationFuture<WaitResponse, WaitMetadata> operationFuture =
217+
grpcClient.waitOperationCallable().futureCall(waitRequest);
218+
ExecutionException exception = assertThrows(ExecutionException.class, operationFuture::get);
219+
assertThat(exception.getCause()).isInstanceOf(ApiException.class);
220+
ApiException apiException = (ApiException) exception.getCause();
221+
222+
// Verify that error details are successfully propagated
223+
assertThat(apiException.getErrorDetails()).isNotNull();
224+
assertThat(apiException.getErrorDetails().getErrorInfo()).isEqualTo(errorInfo);
225+
} finally {
226+
grpcClient.close();
227+
grpcClient.awaitTermination(
228+
TestClientInitializer.AWAIT_TERMINATION_SECONDS, TimeUnit.SECONDS);
229+
}
230+
}
196231
}

sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcOperationSnapshot.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
package com.google.api.gax.grpc;
3131

3232
import com.google.api.gax.longrunning.OperationSnapshot;
33+
import com.google.api.gax.rpc.ErrorDetails;
3334
import com.google.api.gax.rpc.StatusCode;
3435
import com.google.longrunning.Operation;
3536
import io.grpc.Status;
@@ -79,6 +80,14 @@ public String getErrorMessage() {
7980
return operation.getError().getMessage();
8081
}
8182

83+
/** {@inheritDoc} */
84+
@Override
85+
public ErrorDetails getErrorDetails() {
86+
return ErrorDetails.builder()
87+
.setRawErrorMessages(operation.getError().getDetailsList())
88+
.build();
89+
}
90+
8291
public static GrpcOperationSnapshot create(Operation operation) {
8392
return new GrpcOperationSnapshot(operation);
8493
}

sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/ProtoOperationTransformers.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ public ResponseT apply(OperationSnapshot operationSnapshot) {
6363
+ operationSnapshot.getErrorMessage(),
6464
null,
6565
operationSnapshot.getErrorCode(),
66-
false);
66+
false,
67+
operationSnapshot.getErrorDetails());
6768
}
6869

6970
if (!(operationSnapshot.getResponse() instanceof Any)) {

sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ProtoOperationTransformersTest.java

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,17 @@
3434
import com.google.api.gax.grpc.ProtoOperationTransformers.MetadataTransformer;
3535
import com.google.api.gax.grpc.ProtoOperationTransformers.ResponseTransformer;
3636
import com.google.api.gax.longrunning.OperationSnapshot;
37+
import com.google.api.gax.rpc.ErrorDetails;
3738
import com.google.api.gax.rpc.UnavailableException;
3839
import com.google.api.gax.rpc.UnknownException;
3940
import com.google.common.truth.Truth;
4041
import com.google.longrunning.Operation;
4142
import com.google.protobuf.Any;
43+
import com.google.rpc.ErrorInfo;
4244
import com.google.rpc.Status;
43-
import com.google.type.Color;
4445
import com.google.type.Money;
4546
import io.grpc.Status.Code;
47+
import java.util.Collections;
4648
import org.junit.jupiter.api.Test;
4749

4850
class ProtoOperationTransformersTest {
@@ -64,11 +66,13 @@ void testAnyResponseTransformer_exception() {
6466
OperationSnapshot operationSnapshot =
6567
GrpcOperationSnapshot.create(
6668
Operation.newBuilder().setResponse(Any.pack(inputMoney)).setError(status).build());
67-
Exception exception =
69+
UnavailableException exception =
6870
assertThrows(UnavailableException.class, () -> transformer.apply(operationSnapshot));
6971
Truth.assertThat(exception)
7072
.hasMessageThat()
7173
.contains("failed with status = GrpcStatusCode{transportCode=UNAVAILABLE}");
74+
Truth.assertThat(exception.getErrorDetails())
75+
.isEqualTo(ErrorDetails.builder().setRawErrorMessages(Collections.emptyList()).build());
7276
}
7377

7478
@Test
@@ -78,7 +82,7 @@ void testAnyResponseTransformer_mismatchedTypes() {
7882
OperationSnapshot operationSnapshot =
7983
GrpcOperationSnapshot.create(
8084
Operation.newBuilder()
81-
.setResponse(Any.pack(Color.getDefaultInstance()))
85+
.setResponse(Any.pack(ErrorInfo.getDefaultInstance()))
8286
.setError(status)
8387
.build());
8488
Exception exception =
@@ -103,11 +107,32 @@ void testAnyMetadataTransformer_mismatchedTypes() {
103107
OperationSnapshot operationSnapshot =
104108
GrpcOperationSnapshot.create(
105109
Operation.newBuilder()
106-
.setMetadata(Any.pack(Color.getDefaultInstance()))
110+
.setMetadata(Any.pack(ErrorInfo.getDefaultInstance()))
107111
.setError(status)
108112
.build());
109113
Exception exception =
110114
assertThrows(UnknownException.class, () -> transformer.apply(operationSnapshot));
111115
Truth.assertThat(exception).hasMessageThat().contains("encountered a problem unpacking it");
112116
}
117+
118+
@Test
119+
void testAnyResponseTransformer_exceptionWithErrorDetails() {
120+
ResponseTransformer<Money> transformer = ResponseTransformer.create(Money.class);
121+
Money inputMoney = Money.newBuilder().setCurrencyCode("USD").build();
122+
ErrorInfo errorInfo =
123+
ErrorInfo.newBuilder().setReason("TEST_REASON").setDomain("googleapis.com").build();
124+
Status status =
125+
Status.newBuilder()
126+
.setCode(Code.UNAVAILABLE.value())
127+
.addDetails(Any.pack(errorInfo))
128+
.build();
129+
OperationSnapshot operationSnapshot =
130+
GrpcOperationSnapshot.create(
131+
Operation.newBuilder().setResponse(Any.pack(inputMoney)).setError(status).build());
132+
133+
UnavailableException exception =
134+
assertThrows(UnavailableException.class, () -> transformer.apply(operationSnapshot));
135+
Truth.assertThat(exception.getErrorDetails()).isNotNull();
136+
Truth.assertThat(exception.getErrorDetails().getErrorInfo()).isEqualTo(errorInfo);
137+
}
113138
}

sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonOperationSnapshot.java

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@
3131

3232
import com.google.api.core.InternalApi;
3333
import com.google.api.gax.longrunning.OperationSnapshot;
34+
import com.google.api.gax.rpc.ErrorDetails;
3435
import com.google.api.gax.rpc.StatusCode;
3536
import com.google.api.gax.rpc.StatusCode.Code;
3637
import com.google.longrunning.Operation;
38+
import java.util.Collections;
3739
import org.jspecify.annotations.NullMarked;
3840

3941
/**
@@ -50,20 +52,23 @@ public class HttpJsonOperationSnapshot implements OperationSnapshot {
5052
private final Object response;
5153
private final StatusCode errorCode;
5254
private final String errorMessage;
55+
private final ErrorDetails errorDetails;
5356

5457
private HttpJsonOperationSnapshot(
5558
String name,
5659
Object metadata,
5760
boolean done,
5861
Object response,
5962
StatusCode errorCode,
60-
String errorMessage) {
63+
String errorMessage,
64+
ErrorDetails errorDetails) {
6165
this.name = name;
6266
this.metadata = metadata;
6367
this.done = done;
6468
this.response = response;
6569
this.errorCode = errorCode;
6670
this.errorMessage = errorMessage;
71+
this.errorDetails = errorDetails;
6772
}
6873

6974
/** {@inheritDoc} */
@@ -102,6 +107,12 @@ public String getErrorMessage() {
102107
return this.errorMessage;
103108
}
104109

110+
/** {@inheritDoc} */
111+
@Override
112+
public ErrorDetails getErrorDetails() {
113+
return this.errorDetails;
114+
}
115+
105116
public static HttpJsonOperationSnapshot create(Operation operation) {
106117
return newBuilder().setOperation(operation).build();
107118
}
@@ -117,6 +128,19 @@ public static class Builder {
117128
private Object response;
118129
private StatusCode errorCode;
119130
private String errorMessage;
131+
private ErrorDetails errorDetails =
132+
ErrorDetails.builder().setRawErrorMessages(Collections.emptyList()).build();
133+
134+
/**
135+
* Sets the LRO error details.
136+
*
137+
* @param errorDetails the LRO error details
138+
* @return the builder instance
139+
*/
140+
Builder setErrorDetails(final ErrorDetails errorDetails) {
141+
this.errorDetails = errorDetails;
142+
return this;
143+
}
120144

121145
public Builder setName(String name) {
122146
this.name = name;
@@ -153,11 +177,14 @@ private Builder setOperation(Operation operation) {
153177
this.errorCode =
154178
HttpJsonStatusCode.of(com.google.rpc.Code.forNumber(operation.getError().getCode()));
155179
this.errorMessage = operation.getError().getMessage();
180+
this.errorDetails =
181+
ErrorDetails.builder().setRawErrorMessages(operation.getError().getDetailsList()).build();
156182
return this;
157183
}
158184

159185
public HttpJsonOperationSnapshot build() {
160-
return new HttpJsonOperationSnapshot(name, metadata, done, response, errorCode, errorMessage);
186+
return new HttpJsonOperationSnapshot(
187+
name, metadata, done, response, errorCode, errorMessage, errorDetails);
161188
}
162189
}
163190
}

sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoOperationTransformers.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ public ResponseT apply(OperationSnapshot operationSnapshot) {
6363
+ operationSnapshot.getErrorMessage(),
6464
null,
6565
operationSnapshot.getErrorCode(),
66-
false);
66+
false,
67+
operationSnapshot.getErrorDetails());
6768
}
6869

6970
if (!(operationSnapshot.getResponse() instanceof Any)) {

sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonOperationSnapshotTest.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@
3333
import static org.junit.jupiter.api.Assertions.assertFalse;
3434
import static org.junit.jupiter.api.Assertions.assertTrue;
3535

36+
import com.google.api.gax.rpc.ErrorDetails;
3637
import com.google.api.gax.rpc.StatusCode.Code;
38+
import com.google.protobuf.Any;
39+
import com.google.protobuf.Empty;
3740
import java.util.ArrayList;
41+
import java.util.Collections;
3842
import org.junit.jupiter.api.Test;
3943

4044
class HttpJsonOperationSnapshotTest {
@@ -86,4 +90,22 @@ void newBuilderTestNotDone() {
8690
assertEquals(HttpJsonStatusCode.of(Code.OK), testOperationSnapshot.getErrorCode());
8791
assertFalse(testOperationSnapshot.isDone());
8892
}
93+
94+
@Test
95+
void newBuilderTestWithErrorDetails() {
96+
ErrorDetails errorDetails =
97+
ErrorDetails.builder()
98+
.setRawErrorMessages(Collections.singletonList(Any.pack(Empty.getDefaultInstance())))
99+
.build();
100+
HttpJsonOperationSnapshot testOperationSnapshot =
101+
HttpJsonOperationSnapshot.newBuilder()
102+
.setName("snapshot-details")
103+
.setMetadata("Dallas")
104+
.setDone(true)
105+
.setError(400, "Bad Request")
106+
.setErrorDetails(errorDetails)
107+
.build();
108+
109+
assertEquals(errorDetails, testOperationSnapshot.getErrorDetails());
110+
}
89111
}

sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoOperationTransformersTest.java

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,17 @@
3434
import com.google.api.gax.httpjson.ProtoOperationTransformers.MetadataTransformer;
3535
import com.google.api.gax.httpjson.ProtoOperationTransformers.ResponseTransformer;
3636
import com.google.api.gax.longrunning.OperationSnapshot;
37+
import com.google.api.gax.rpc.ErrorDetails;
3738
import com.google.api.gax.rpc.UnavailableException;
3839
import com.google.api.gax.rpc.UnknownException;
3940
import com.google.common.truth.Truth;
4041
import com.google.longrunning.Operation;
4142
import com.google.protobuf.Any;
4243
import com.google.rpc.Code;
44+
import com.google.rpc.ErrorInfo;
4345
import com.google.rpc.Status;
44-
import com.google.type.Color;
4546
import com.google.type.Money;
47+
import java.util.Collections;
4648
import org.junit.jupiter.api.Test;
4749

4850
class ProtoOperationTransformersTest {
@@ -96,11 +98,13 @@ void testAnyResponseTransformer_exception() {
9698
HttpJsonOperationSnapshot.create(
9799
Operation.newBuilder().setResponse(Any.pack(inputMoney)).setError(status).build());
98100

99-
Exception exception =
101+
UnavailableException exception =
100102
assertThrows(UnavailableException.class, () -> transformer.apply(operationSnapshot));
101103
Truth.assertThat(exception)
102104
.hasMessageThat()
103105
.contains("failed with status = HttpJsonStatusCode{statusCode=UNAVAILABLE}");
106+
Truth.assertThat(exception.getErrorDetails())
107+
.isEqualTo(ErrorDetails.builder().setRawErrorMessages(Collections.emptyList()).build());
104108
}
105109

106110
@Test
@@ -110,7 +114,7 @@ void testAnyResponseTransformer_mismatchedTypes() {
110114
OperationSnapshot operationSnapshot =
111115
HttpJsonOperationSnapshot.create(
112116
Operation.newBuilder()
113-
.setResponse(Any.pack(Color.getDefaultInstance()))
117+
.setResponse(Any.pack(ErrorInfo.getDefaultInstance()))
114118
.setError(status)
115119
.build());
116120
Exception exception =
@@ -135,11 +139,32 @@ void testAnyMetadataTransformer_mismatchedTypes() {
135139
OperationSnapshot operationSnapshot =
136140
HttpJsonOperationSnapshot.create(
137141
Operation.newBuilder()
138-
.setMetadata(Any.pack(Color.getDefaultInstance()))
142+
.setMetadata(Any.pack(ErrorInfo.getDefaultInstance()))
139143
.setError(status)
140144
.build());
141145
Exception exception =
142146
assertThrows(UnknownException.class, () -> transformer.apply(operationSnapshot));
143147
Truth.assertThat(exception).hasMessageThat().contains("encountered a problem unpacking it");
144148
}
149+
150+
@Test
151+
void testAnyResponseTransformer_exceptionWithErrorDetails() {
152+
ResponseTransformer<Money> transformer = ResponseTransformer.create(Money.class);
153+
Money inputMoney = Money.newBuilder().setCurrencyCode("USD").build();
154+
ErrorInfo errorInfo =
155+
ErrorInfo.newBuilder().setReason("TEST_REASON").setDomain("googleapis.com").build();
156+
Status status =
157+
Status.newBuilder()
158+
.setCode(Code.UNAVAILABLE.getNumber())
159+
.addDetails(Any.pack(errorInfo))
160+
.build();
161+
OperationSnapshot operationSnapshot =
162+
HttpJsonOperationSnapshot.create(
163+
Operation.newBuilder().setResponse(Any.pack(inputMoney)).setError(status).build());
164+
165+
UnavailableException exception =
166+
assertThrows(UnavailableException.class, () -> transformer.apply(operationSnapshot));
167+
Truth.assertThat(exception.getErrorDetails()).isNotNull();
168+
Truth.assertThat(exception.getErrorDetails().getErrorInfo()).isEqualTo(errorInfo);
169+
}
145170
}

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/longrunning/OperationSnapshot.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
*/
3030
package com.google.api.gax.longrunning;
3131

32+
import com.google.api.gax.rpc.ErrorDetails;
3233
import com.google.api.gax.rpc.StatusCode;
34+
import java.util.Collections;
3335
import org.jspecify.annotations.NullMarked;
3436

3537
/**
@@ -67,4 +69,14 @@ public interface OperationSnapshot {
6769
* or if it succeeded, returns null.
6870
*/
6971
String getErrorMessage();
72+
73+
/**
74+
* If the operation is done and it failed, returns the ErrorDetails; if the operation is not done
75+
* or if it succeeded, returns an empty ErrorDetails object.
76+
*
77+
* @return the error details if the operation failed, or an empty ErrorDetails object
78+
*/
79+
default ErrorDetails getErrorDetails() {
80+
return ErrorDetails.builder().setRawErrorMessages(Collections.emptyList()).build();
81+
}
7082
}

0 commit comments

Comments
 (0)