Repository: googleapis/java-storage
Pull request #2532 (2024-05-07 21:21:00)

fix: update grpc WriteObject response handling to provide context when a failure happens
Follow up to #2527 

This updated errors from grpc chunked uploads to have messages that include useful debugging information.

When a response is received but doesn't validate with the expected state: 
```
com.google.cloud.storage.StorageException: Attempt to append to already finalized resumable session.
	|> [
	|> 	com.google.storage.v2.WriteObjectRequest{
	|> 		upload_id: uploadId
	|> 		checksummed_data: {range: [0:262144]}
	|> 	}
	|> ]
	|  
	|< com.google.storage.v2.WriteObjectResponse{
	|< 	resource {
	|< 	  name: "obj"
	|< 	  size: 524288
	|< 	}
	|< }
	|
```

When an error is returned by GCS:
```
com.google.cloud.storage.StorageException: Client side data loss detected. Attempt to append to a resumable session with an offset higher than the backend has
	|> [
	|> 	com.google.storage.v2.WriteObjectRequest{
	|> 		upload_id: uploadId
	|> 		checksummed_data: {range: [262144:524288]}
	|> 	}
	|> ]
	|  
	|< Status{code=OUT_OF_RANGE, description=Upload request started at offset '262144', which is past expected offset '0'., cause=null}
	|< Metadata(content-type=application/grpc)
	| 
```

Also update exception propagation to add original call context as a suppressed exception in order to allow maintaining originating frames of the async failure.


Patch:
diff --git a/.github/workflows/unmanaged_dependency_check.yaml b/.github/workflows/unmanaged_dependency_check.yaml
index b7ee2ca7..f15d6521 100644
--- a/.github/workflows/unmanaged_dependency_check.yaml
+++ b/.github/workflows/unmanaged_dependency_check.yaml
@@ -17,6 +17,6 @@ jobs:
           # repository
           .kokoro/build.sh
       - name: Unmanaged dependency check
-        uses: googleapis/sdk-platform-java/java-shared-dependencies/unmanaged-dependency-check@google-cloud-shared-dependencies/v3.29.0
+        uses: googleapis/sdk-platform-java/java-shared-dependencies/unmanaged-dependency-check@google-cloud-shared-dependencies/v3.30.0
         with:
           bom-path: google-cloud-storage-bom/pom.xml
diff --git a/.kokoro/presubmit/graalvm-native-17.cfg b/.kokoro/presubmit/graalvm-native-17.cfg
index 7c1a9bb6..8bcc468e 100644
--- a/.kokoro/presubmit/graalvm-native-17.cfg
+++ b/.kokoro/presubmit/graalvm-native-17.cfg
@@ -3,7 +3,7 @@
 # Configure the docker image for kokoro-trampoline.
 env_vars: {
   key: "TRAMPOLINE_IMAGE"
-  value: "gcr.io/cloud-devrel-public-resources/graalvm_sdk_platform_b:3.29.0"
+  value: "gcr.io/cloud-devrel-public-resources/graalvm_sdk_platform_b:3.30.0"
 }
 
 env_vars: {
diff --git a/.kokoro/presubmit/graalvm-native.cfg b/.kokoro/presubmit/graalvm-native.cfg
index 39055d62..65d8c499 100644
--- a/.kokoro/presubmit/graalvm-native.cfg
+++ b/.kokoro/presubmit/graalvm-native.cfg
@@ -3,7 +3,7 @@
 # Configure the docker image for kokoro-trampoline.
 env_vars: {
   key: "TRAMPOLINE_IMAGE"
-  value: "gcr.io/cloud-devrel-public-resources/graalvm_sdk_platform_a:3.29.0"
+  value: "gcr.io/cloud-devrel-public-resources/graalvm_sdk_platform_a:3.30.0"
 }
 
 env_vars: {
diff --git a/google-cloud-storage-bom/pom.xml b/google-cloud-storage-bom/pom.xml
index 69282077..e5f9ba43 100644
--- a/google-cloud-storage-bom/pom.xml
+++ b/google-cloud-storage-bom/pom.xml
@@ -24,7 +24,7 @@
   <parent>
     <groupId>com.google.cloud</groupId>
     <artifactId>sdk-platform-java-config</artifactId>
-    <version>3.29.0</version>
+    <version>3.30.0</version>
     <relativePath/>
   </parent>
 
diff --git a/google-cloud-storage/pom.xml b/google-cloud-storage/pom.xml
index 4528c5b8..0f392709 100644
--- a/google-cloud-storage/pom.xml
+++ b/google-cloud-storage/pom.xml
@@ -16,7 +16,7 @@
   </parent>
   <properties>
     <site.installationModule>google-cloud-storage</site.installationModule>
-    <pubsub-proto.version>1.111.2</pubsub-proto.version>
+    <pubsub-proto.version>1.111.3</pubsub-proto.version>
     <junit-platform.version>5.10.2</junit-platform.version>
   </properties>
   <dependencies>
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/AsyncStorageTaskException.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/AsyncStorageTaskException.java
new file mode 100644
index 00000000..7d7b585d
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/AsyncStorageTaskException.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2024 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.storage;
+
+/**
+ * This exception is used to preserve the caller's stacktrace when invoking an async task in a sync
+ * context. It will be added as a suppressed exception when propagating the async exception. This
+ * allows callers to catch ApiException thrown in an async operation, while still maintaining the
+ * call site.
+ */
+public final class AsyncStorageTaskException extends RuntimeException {
+  // mimic of com.google.api.gax.rpc.AsyncTaskException which doesn't have a public constructor
+  // if that class is ever made public, make this class extend it
+  AsyncStorageTaskException() {
+    super("Asynchronous task failed");
+  }
+}
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedChunkedResumableWritableByteChannel.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedChunkedResumableWritableByteChannel.java
index bbcd986a..c3c3cd09 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedChunkedResumableWritableByteChannel.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedChunkedResumableWritableByteChannel.java
@@ -21,6 +21,7 @@ import static com.google.cloud.storage.GrpcUtils.contextWithBucketName;
 import com.google.api.core.SettableApiFuture;
 import com.google.api.gax.grpc.GrpcCallContext;
 import com.google.api.gax.retrying.ResultRetryAlgorithm;
+import com.google.api.gax.rpc.ApiException;
 import com.google.api.gax.rpc.ApiStreamObserver;
 import com.google.api.gax.rpc.ClientStreamingCallable;
 import com.google.api.gax.rpc.OutOfRangeException;
@@ -208,14 +209,19 @@ final class GapicUnbufferedChunkedResumableWritableByteChannel
         deps,
         alg,
         () -> {
-          Observer observer = new Observer(content, finalizing);
+          Observer observer = new Observer(content, finalizing, segments, internalContext);
           ApiStreamObserver<WriteObjectRequest> write = callable.clientStreamingCall(observer);
 
           for (WriteObjectRequest message : segments) {
             write.onNext(message);
           }
           write.onCompleted();
-          observer.await();
+          try {
+            observer.await();
+          } catch (Throwable t) {
+            t.addSuppressed(new AsyncStorageTaskException());
+            throw t;
+          }
           return null;
         },
         Decoder.identity());
@@ -230,13 +236,21 @@ final class GapicUnbufferedChunkedResumableWritableByteChannel
 
     private final RewindableContent content;
     private final boolean finalizing;
+    private final List<WriteObjectRequest> segments;
+    private final GrpcCallContext context;
 
     private final SettableApiFuture<Void> invocationHandle;
     private volatile WriteObjectResponse last;
 
-    Observer(@Nullable RewindableContent content, boolean finalizing) {
+    Observer(
+        @Nullable RewindableContent content,
+        boolean finalizing,
+        @NonNull List<WriteObjectRequest> segments,
+        GrpcCallContext context) {
       this.content = content;
       this.finalizing = finalizing;
+      this.segments = segments;
+      this.context = context;
       this.invocationHandle = SettableApiFuture.create();
     }
 
@@ -250,10 +264,20 @@ final class GapicUnbufferedChunkedResumableWritableByteChannel
       if (t instanceof OutOfRangeException) {
         OutOfRangeException oore = (OutOfRangeException) t;
         open = false;
-        invocationHandle.setException(
-            ResumableSessionFailureScenario.SCENARIO_5.toStorageException());
-      } else {
-        invocationHandle.setException(t);
+        StorageException storageException =
+            ResumableSessionFailureScenario.SCENARIO_5.toStorageException(
+                segments, null, context, oore);
+        invocationHandle.setException(storageException);
+      } else if (t instanceof ApiException) {
+        // use StorageExceptions logic to translate from ApiException to our status codes ensuring
+        // things fall in line with our retry handlers.
+        // This is suboptimal, as it will initialize a second exception, however this is the
+        // unusual case, and it should not cause a significant overhead given its rarity.
+        StorageException tmp = StorageException.asStorageException((ApiException) t);
+        StorageException storageException =
+            ResumableSessionFailureScenario.toStorageException(
+                tmp.getCode(), tmp.getMessage(), tmp.getReason(), segments, null, context, t);
+        invocationHandle.setException(storageException);
       }
     }
 
@@ -276,7 +300,8 @@ final class GapicUnbufferedChunkedResumableWritableByteChannel
             writeCtx.getTotalSentBytes().set(persistedSize);
             writeCtx.getConfirmedBytes().set(persistedSize);
           } else {
-            throw ResumableSessionFailureScenario.SCENARIO_7.toStorageException();
+            throw ResumableSessionFailureScenario.SCENARIO_7.toStorageException(
+                segments, last, context, null);
           }
         } else if (finalizing && last.hasResource()) {
           long totalSentBytes = writeCtx.getTotalSentBytes().get();
@@ -285,22 +310,28 @@ final class GapicUnbufferedChunkedResumableWritableByteChannel
             writeCtx.getConfirmedBytes().set(finalSize);
             resultFuture.set(last);
           } else if (finalSize < totalSentBytes) {
-            throw ResumableSessionFailureScenario.SCENARIO_4_1.toStorageException();
+            throw ResumableSessionFailureScenario.SCENARIO_4_1.toStorageException(
+                segments, last, context, null);
           } else {
-            throw ResumableSessionFailureScenario.SCENARIO_4_2.toStorageException();
+            throw ResumableSessionFailureScenario.SCENARIO_4_2.toStorageException(
+                segments, last, context, null);
           }
         } else if (!finalizing && last.hasResource()) {
-          throw ResumableSessionFailureScenario.SCENARIO_1.toStorageException();
+          throw ResumableSessionFailureScenario.SCENARIO_1.toStorageException(
+              segments, last, context, null);
         } else if (finalizing && last.hasPersistedSize()) {
           long totalSentBytes = writeCtx.getTotalSentBytes().get();
           long persistedSize = last.getPersistedSize();
           if (persistedSize < totalSentBytes) {
-            throw ResumableSessionFailureScenario.SCENARIO_3.toStorageException();
+            throw ResumableSessionFailureScenario.SCENARIO_3.toStorageException(
+                segments, last, context, null);
           } else {
-            throw ResumableSessionFailureScenario.SCENARIO_2.toStorageException();
+            throw ResumableSessionFailureScenario.SCENARIO_2.toStorageException(
+                segments, last, context, null);
           }
         } else {
-          throw ResumableSessionFailureScenario.SCENARIO_0.toStorageException();
+          throw ResumableSessionFailureScenario.SCENARIO_0.toStorageException(
+              segments, last, context, null);
         }
       } catch (Throwable se) {
         open = false;
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/ResumableSessionFailureScenario.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/ResumableSessionFailureScenario.java
index 0bccb2ff..294d481c 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/ResumableSessionFailureScenario.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/ResumableSessionFailureScenario.java
@@ -16,19 +16,31 @@
 
 package com.google.cloud.storage;
 
+import static com.google.cloud.storage.Utils.ifNonNull;
+
 import com.google.api.client.http.HttpHeaders;
 import com.google.api.client.http.HttpResponse;
 import com.google.api.client.http.HttpResponseException;
+import com.google.api.gax.grpc.GrpcCallContext;
+import com.google.api.gax.rpc.ApiException;
 import com.google.cloud.BaseServiceException;
 import com.google.cloud.storage.StorageException.IOExceptionCallable;
 import com.google.common.io.CharStreams;
+import com.google.protobuf.MessageOrBuilder;
+import com.google.storage.v2.ChecksummedData;
+import com.google.storage.v2.ObjectChecksums;
+import com.google.storage.v2.WriteObjectRequest;
+import com.google.storage.v2.WriteObjectResponse;
+import io.grpc.StatusRuntimeException;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.function.Consumer;
 import java.util.function.Predicate;
 import javax.annotation.ParametersAreNonnullByDefault;
+import org.checkerframework.checker.nullness.qual.NonNull;
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 @ParametersAreNonnullByDefault
@@ -69,6 +81,10 @@ enum ResumableSessionFailureScenario {
   private static final String PREFIX_I = "\t|< ";
   private static final String PREFIX_O = "\t|> ";
   private static final String PREFIX_X = "\t|  ";
+  // define some constants for tab widths that are more compressed that the literals
+  private static final String T1 = "\t";
+  private static final String T2 = "\t\t";
+  private static final String T3 = "\t\t\t";
 
   private static final Predicate<String> includedHeaders =
       matches("Content-Length")
@@ -78,6 +94,7 @@ enum ResumableSessionFailureScenario {
           .or(matches("Range"))
           .or(startsWith("X-Goog-Stored-"))
           .or(matches("X-Goog-GCS-Idempotency-Token"))
+          .or(matches("X-Goog-request-params"))
           .or(matches("X-GUploader-UploadID"));
 
   private static final Predicate<Map.Entry<String, ?>> includeHeader =
@@ -116,8 +133,12 @@ enum ResumableSessionFailureScenario {
     return toStorageException(code, message, reason, uploadId, resp, cause, contentCallable);
   }
 
-  StorageException toStorageException() {
-    return new StorageException(code, message, reason, null);
+  StorageException toStorageException(
+      @NonNull List<@NonNull WriteObjectRequest> reqs,
+      @Nullable WriteObjectResponse resp,
+      @NonNull GrpcCallContext context,
+      @Nullable Throwable cause) {
+    return toStorageException(code, message, reason, reqs, resp, context, cause);
   }
 
   static StorageException toStorageException(
@@ -136,6 +157,102 @@ enum ResumableSessionFailureScenario {
     return se;
   }
 
+  static StorageException toStorageException(
+      int code,
+      String message,
+      @Nullable String reason,
+      @NonNull List<@NonNull WriteObjectRequest> reqs,
+      @Nullable WriteObjectResponse resp,
+      @NonNull GrpcCallContext context,
+      @Nullable Throwable cause) {
+    final StringBuilder sb = new StringBuilder();
+    sb.append(message);
+    // request context
+    Map<String, List<String>> extraHeaders = context.getExtraHeaders();
+    recordHeadersTo(extraHeaders, PREFIX_O, sb);
+    int length = reqs.size();
+    for (int i = 0; i < length; i++) {
+      if (i == 0) {
+        sb.append("\n").append(PREFIX_O).append("[");
+      } else {
+        sb.append(",");
+      }
+      WriteObjectRequest req = reqs.get(i);
+      sb.append("\n").append(PREFIX_O).append(T1).append(req.getClass().getName()).append("{");
+      if (req.hasUploadId()) {
+        sb.append("\n").append(PREFIX_O).append(T2).append("upload_id: ").append(req.getUploadId());
+      }
+      long writeOffset = req.getWriteOffset();
+      if (req.hasChecksummedData()) {
+        ChecksummedData checksummedData = req.getChecksummedData();
+        sb.append("\n").append(PREFIX_O).append(T2);
+        sb.append(
+            String.format(
+                "checksummed_data: {range: [%d:%d]",
+                writeOffset, writeOffset + checksummedData.getContent().size()));
+        if (checksummedData.hasCrc32C()) {
+          sb.append(", crc32c: ").append(checksummedData.getCrc32C());
+        }
+        sb.append("}");
+      } else {
+        sb.append("\n").append(PREFIX_O).append(T2).append("write_offset: ").append(writeOffset);
+      }
+      if (req.getFinishWrite()) {
+        sb.append("\n").append(PREFIX_O).append(T2).append("finish_write: true");
+      }
+      if (req.hasObjectChecksums()) {
+        ObjectChecksums objectChecksums = req.getObjectChecksums();
+        sb.append("\n").append(PREFIX_O).append(T2).append("object_checksums: ").append("{");
+        fmt(objectChecksums, PREFIX_O, T3, sb);
+        sb.append("\n").append(PREFIX_O).append(T2).append("}");
+      }
+      sb.append("\n").append(PREFIX_O).append("\t}");
+      if (i == length - 1) {
+        sb.append("\n").append(PREFIX_O).append("]");
+      }
+    }
+
+    sb.append("\n").append(PREFIX_X);
+
+    // response context
+    if (resp != null) {
+      sb.append("\n").append(PREFIX_I).append(resp.getClass().getName()).append("{");
+      fmt(resp, PREFIX_I, T1, sb);
+      sb.append("\n").append(PREFIX_I).append("}");
+      sb.append("\n").append(PREFIX_X);
+    }
+
+    if (cause != null) {
+      if (cause instanceof ApiException) {
+        ApiException apiException = (ApiException) cause;
+        Throwable cause1 = apiException.getCause();
+        if (cause1 instanceof StatusRuntimeException) {
+          StatusRuntimeException statusRuntimeException = (StatusRuntimeException) cause1;
+          sb.append("\n").append(PREFIX_I).append(statusRuntimeException.getStatus());
+          ifNonNull(
+              statusRuntimeException.getTrailers(),
+              t -> sb.append("\n").append(PREFIX_I).append(t));
+        } else {
+          sb.append("\n")
+              .append(PREFIX_I)
+              .append("code: ")
+              .append(apiException.getStatusCode().toString());
+          ifNonNull(
+              apiException.getReason(),
+              r -> sb.append("\n").append(PREFIX_I).append("reason: ").append(r));
+          ifNonNull(
+              apiException.getDomain(),
+              d -> sb.append("\n").append(PREFIX_I).append("domain: ").append(d));
+          ifNonNull(
+              apiException.getErrorDetails(),
+              e -> sb.append("\n").append(PREFIX_I).append("errorDetails: ").append(e));
+        }
+        sb.append("\n").append(PREFIX_X);
+      }
+    }
+    return new StorageException(code, sb.toString(), reason, cause);
+  }
+
   static StorageException toStorageException(
       int overrideCode,
       String message,
@@ -213,14 +330,21 @@ enum ResumableSessionFailureScenario {
   }
 
   private static void recordHeaderTo(HttpHeaders h, String prefix, StringBuilder sb) {
-    h.entrySet().stream()
-        .filter(includeHeader)
-        .forEach(
-            e -> {
-              String key = e.getKey();
-              String value = headerValueToString(e.getValue());
-              sb.append("\n").append(prefix).append(key).append(": ").append(value);
-            });
+    h.entrySet().stream().filter(includeHeader).forEach(writeHeaderValue(prefix, sb));
+  }
+
+  private static void recordHeadersTo(
+      Map<String, List<String>> headers, String prefix, StringBuilder sb) {
+    headers.entrySet().stream().filter(includeHeader).forEach(writeHeaderValue(prefix, sb));
+  }
+
+  private static <V> Consumer<Map.Entry<String, V>> writeHeaderValue(
+      String prefix, StringBuilder sb) {
+    return e -> {
+      String key = e.getKey();
+      String value = headerValueToString(e.getValue());
+      sb.append("\n").append(prefix).append(key).append(": ").append(value);
+    };
   }
 
   private static String headerValueToString(Object o) {
@@ -233,4 +357,18 @@ enum ResumableSessionFailureScenario {
 
     return o.toString();
   }
+
+  private static void fmt(
+      MessageOrBuilder msg,
+      @SuppressWarnings("SameParameterValue") String prefix,
+      String indentation,
+      StringBuilder sb) {
+    String string = msg.toString();
+    // drop the final new line before prefixing
+    string = string.replaceAll("\n$", "");
+    sb.append("\n")
+        .append(prefix)
+        .append(indentation)
+        .append(string.replaceAll("\r?\n", "\n" + prefix + indentation));
+  }
 }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java
index eeb24a44..63708115 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java
@@ -139,13 +139,19 @@ public final class StorageException extends BaseHttpServiceException {
     }
     // If there is a gRPC exception in our cause change pull it's error message up to be our
     // message otherwise, create a generic error message with the status code.
-    String statusCodeName = statusCode.getCode().name();
-    String statusExceptionMessage = getStatusExceptionMessage(apiEx);
-
-    String message;
-    if (statusExceptionMessage != null) {
-      message = statusCodeName + ": " + statusExceptionMessage;
-    } else {
+    String message = null;
+    if (apiEx.getCause() != null) {
+      Throwable cause = apiEx.getCause();
+      if (cause instanceof StatusRuntimeException || cause instanceof StatusException) {
+        message = cause.getMessage();
+      }
+      // if not a grpc exception fall through to the default handling
+    }
+    if (message == null && apiEx.getMessage() != null) {
+      message = apiEx.getMessage();
+    }
+    if (message == null) {
+      String statusCodeName = statusCode.getCode().name();
       message = "Error: " + statusCodeName;
     }
 
@@ -198,19 +204,6 @@ public final class StorageException extends BaseHttpServiceException {
     }
   }
 
-  @Nullable
-  private static String getStatusExceptionMessage(Exception apiEx) {
-    if (apiEx.getMessage() != null) {
-      return apiEx.getMessage();
-    } else {
-      Throwable cause = apiEx.getCause();
-      if (cause instanceof StatusRuntimeException || cause instanceof StatusException) {
-        return cause.getMessage();
-      }
-      return null;
-    }
-  }
-
   @Nullable
   private static ApiException asApiExceptionOrNull(Throwable cause) {
     if (cause instanceof ApiException) {
diff --git a/pom.xml b/pom.xml
index 024837fe..88d0be64 100644
--- a/pom.xml
+++ b/pom.xml
@@ -14,7 +14,7 @@
   <parent>
     <groupId>com.google.cloud</groupId>
     <artifactId>sdk-platform-java-config</artifactId>
-    <version>3.29.0</version>
+    <version>3.30.0</version>
   </parent>
 
   <developers>
@@ -86,7 +86,7 @@
       <dependency>
         <groupId>com.google.cloud</groupId>
         <artifactId>google-cloud-pubsub</artifactId>
-        <version>1.129.2</version>
+        <version>1.129.3</version>
         <scope>test</scope>
       </dependency>
       <dependency>
diff --git a/proto-google-cloud-storage-v2/src/main/proto/google/storage/v2/storage.proto b/proto-google-cloud-storage-v2/src/main/proto/google/storage/v2/storage.proto
index 62922ead..0f9758f1 100644
--- a/proto-google-cloud-storage-v2/src/main/proto/google/storage/v2/storage.proto
+++ b/proto-google-cloud-storage-v2/src/main/proto/google/storage/v2/storage.proto
@@ -1,4 +1,4 @@
-// Copyright 2023 Google LLC
+// Copyright 2024 Google LLC
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
diff --git a/samples/install-without-bom/pom.xml b/samples/install-without-bom/pom.xml
index 610ecce6..e6bec573 100644
--- a/samples/install-without-bom/pom.xml
+++ b/samples/install-without-bom/pom.xml
@@ -66,7 +66,7 @@
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-pubsub</artifactId>
-      <version>1.129.2</version>
+      <version>1.129.3</version>
       <scope>test</scope>
     </dependency>
     <dependency>
diff --git a/samples/native-image-sample/pom.xml b/samples/native-image-sample/pom.xml
index 59ce2ff1..d1992f8b 100644
--- a/samples/native-image-sample/pom.xml
+++ b/samples/native-image-sample/pom.xml
@@ -61,7 +61,7 @@
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-pubsub</artifactId>
-      <version>1.129.2</version>
+      <version>1.129.3</version>
       <scope>test</scope>
     </dependency>
     <dependency>
diff --git a/samples/snapshot/pom.xml b/samples/snapshot/pom.xml
index 050692d3..a1a3931a 100644
--- a/samples/snapshot/pom.xml
+++ b/samples/snapshot/pom.xml
@@ -58,7 +58,7 @@
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-pubsub</artifactId>
-      <version>1.129.2</version>
+      <version>1.129.3</version>
       <scope>test</scope>
     </dependency>
   </dependencies>
diff --git a/samples/snippets/pom.xml b/samples/snippets/pom.xml
index f87f8d33..d27887a4 100644
--- a/samples/snippets/pom.xml
+++ b/samples/snippets/pom.xml
@@ -76,7 +76,7 @@
     <dependency>
       <groupId>com.google.cloud</groupId>
       <artifactId>google-cloud-pubsub</artifactId>
-      <version>1.129.2</version>
+      <version>1.129.3</version>
       <scope>test</scope>
     </dependency>
   </dependencies>
diff --git a/storage-shared-benchmarking/pom.xml b/storage-shared-benchmarking/pom.xml
index cb946f88..723f11f6 100644
--- a/storage-shared-benchmarking/pom.xml
+++ b/storage-shared-benchmarking/pom.xml
@@ -22,7 +22,7 @@
     <dependency>
       <groupId>info.picocli</groupId>
       <artifactId>picocli</artifactId>
-      <version>4.7.5</version>
+      <version>4.7.6</version>
     </dependency>
     <dependency>
       <groupId>com.google.cloud</groupId>


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/ResumableSessionFailureScenarioTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/ResumableSessionFailureScenarioTest.java
index 0a53a4bb..4c17e19c 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/ResumableSessionFailureScenarioTest.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/ResumableSessionFailureScenarioTest.java
@@ -16,8 +16,12 @@
 
 package com.google.cloud.storage;
 
+import static com.google.cloud.storage.ByteSizeConstants._256KiB;
+import static com.google.cloud.storage.ByteSizeConstants._512KiB;
+import static com.google.cloud.storage.ResumableSessionFailureScenario.SCENARIO_1;
 import static com.google.cloud.storage.ResumableSessionFailureScenario.isContinue;
 import static com.google.cloud.storage.ResumableSessionFailureScenario.isOk;
+import static com.google.cloud.storage.TestUtils.assertAll;
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.api.client.http.EmptyContent;
@@ -26,8 +30,24 @@ import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpResponse;
 import com.google.api.client.json.gson.GsonFactory;
 import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.gax.grpc.GrpcCallContext;
+import com.google.api.gax.grpc.GrpcStatusCode;
+import com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.ApiExceptionFactory;
 import com.google.api.services.storage.model.StorageObject;
+import com.google.cloud.storage.it.ChecksummedTestContent;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.protobuf.ByteString;
+import com.google.storage.v2.ChecksummedData;
+import com.google.storage.v2.Object;
+import com.google.storage.v2.ObjectChecksums;
+import com.google.storage.v2.WriteObjectRequest;
+import com.google.storage.v2.WriteObjectResponse;
+import io.grpc.Metadata;
+import io.grpc.Status.Code;
+import io.grpc.StatusRuntimeException;
+import io.grpc.internal.GrpcUtil;
 import java.io.IOException;
 import java.math.BigInteger;
 import java.nio.charset.StandardCharsets;
@@ -158,6 +178,117 @@ public final class ResumableSessionFailureScenarioTest {
     assertThat(storageException).hasMessageThat().contains("|< x-goog-gcs-idempotency-token: 5");
   }
 
+  @Test
+  public void grpc_response() throws Exception {
+    ChecksummedTestContent content =
+        ChecksummedTestContent.of(DataGenerator.base64Characters().genBytes(_256KiB));
+    WriteObjectRequest req1 =
+        WriteObjectRequest.newBuilder()
+            .setUploadId("uploadId")
+            .setChecksummedData(
+                ChecksummedData.newBuilder()
+                    .setContent(
+                        ByteString.copyFrom(DataGenerator.base64Characters().genBytes(_256KiB)))
+                    .build())
+            .build();
+    WriteObjectRequest req2 =
+        WriteObjectRequest.newBuilder()
+            .setWriteOffset(_256KiB)
+            .setChecksummedData(
+                ChecksummedData.newBuilder()
+                    .setContent(ByteString.copyFrom(content.getBytes()))
+                    .setCrc32C(content.getCrc32c())
+                    .build())
+            .build();
+    WriteObjectRequest req3 =
+        WriteObjectRequest.newBuilder()
+            .setWriteOffset(_512KiB)
+            .setFinishWrite(true)
+            .setObjectChecksums(
+                ObjectChecksums.newBuilder()
+                    .setCrc32C(345)
+                    .setMd5Hash(ByteString.copyFromUtf8("asdf"))
+                    .build())
+            .build();
+    WriteObjectResponse resp1 =
+        WriteObjectResponse.newBuilder()
+            .setResource(Object.newBuilder().setName("obj").setSize(_512KiB).build())
+            .build();
+    GrpcCallContext context =
+        GrpcCallContext.createDefault()
+            .withExtraHeaders(
+                ImmutableMap.of(
+                    "x-goog-request-params",
+                    ImmutableList.of("bucket=projects/_/bucket/bucket-name")));
+    StorageException se =
+        SCENARIO_1.toStorageException(ImmutableList.of(req1, req2, req3), resp1, context, null);
+    assertAll(
+        () ->
+            assertThat(se)
+                .hasMessageThat()
+                .contains("x-goog-request-params: bucket=projects/_/bucket/bucket-name"),
+        () -> assertThat(se).hasMessageThat().contains("upload_id: "),
+        () -> assertThat(se).hasMessageThat().contains("0:262144"),
+        () -> assertThat(se).hasMessageThat().contains(", crc32c: "), // from ChecksummedData
+        () -> assertThat(se).hasMessageThat().contains("write_offset: "),
+        () -> assertThat(se).hasMessageThat().contains("finish_write: "),
+        () -> assertThat(se).hasMessageThat().contains("object_checksums: "),
+        () -> assertThat(se).hasMessageThat().contains("crc32c: "), // from object_checksums
+        () -> assertThat(se).hasMessageThat().contains("md5_hash: "),
+        () -> assertThat(se).hasMessageThat().contains("resource {"));
+  }
+
+  @Test
+  public void grpc_apiException() throws Exception {
+    WriteObjectRequest req1 =
+        WriteObjectRequest.newBuilder()
+            .setUploadId("uploadId")
+            .setChecksummedData(
+                ChecksummedData.newBuilder()
+                    .setContent(
+                        ByteString.copyFrom(DataGenerator.base64Characters().genBytes(_256KiB)))
+                    .build())
+            .build();
+    GrpcCallContext context = Retrying.newCallContext();
+    Code code = Code.FAILED_PRECONDITION;
+    Metadata trailers = new Metadata();
+    trailers.put(GrpcUtil.USER_AGENT_KEY, "test-class/");
+    StatusRuntimeException statusRuntimeException =
+        code.toStatus().withDescription("precondition did not hold").asRuntimeException(trailers);
+    ApiException apiException =
+        ApiExceptionFactory.createException(statusRuntimeException, GrpcStatusCode.of(code), true);
+
+    StorageException se =
+        SCENARIO_1.toStorageException(ImmutableList.of(req1), null, context, apiException);
+    assertAll(
+        () -> assertThat(se).hasMessageThat().contains("upload_id: "),
+        () -> assertThat(se).hasMessageThat().contains("0:262144"),
+        () -> assertThat(se).hasMessageThat().doesNotContain("WriteObjectResponse"),
+        () -> assertThat(se).hasMessageThat().contains("Status{code=FAILED_PRECONDITION"),
+        () -> assertThat(se).hasMessageThat().contains("user-agent=test-class/"));
+  }
+
+  @Test
+  public void grpc_nonApiException() throws Exception {
+    WriteObjectRequest req1 =
+        WriteObjectRequest.newBuilder()
+            .setUploadId("uploadId")
+            .setChecksummedData(
+                ChecksummedData.newBuilder()
+                    .setContent(
+                        ByteString.copyFrom(DataGenerator.base64Characters().genBytes(_256KiB)))
+                    .build())
+            .build();
+    GrpcCallContext context = Retrying.newCallContext();
+    Cause cause = new Cause();
+    StorageException se =
+        SCENARIO_1.toStorageException(ImmutableList.of(req1), null, context, cause);
+    assertAll(
+        () -> assertThat(se).hasMessageThat().contains("upload_id: "),
+        () -> assertThat(se).hasMessageThat().contains("0:262144"),
+        () -> assertThat(se).hasMessageThat().doesNotContain("WriteObjectResponse"));
+  }
+
   private static final class Cause extends RuntimeException {
 
     private Cause() {