diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultStorageRetryStrategy.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultStorageRetryStrategy.java index b9a8df0b5217..813b1e0b7170 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultStorageRetryStrategy.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/DefaultStorageRetryStrategy.java @@ -129,7 +129,13 @@ private RetryResult deepShouldRetry(BaseServiceException baseServiceException) { if (baseServiceException.getCode() == BaseServiceException.UNKNOWN_CODE && baseServiceException.getReason() == null) { final Throwable cause = baseServiceException.getCause(); - if (cause instanceof IOException) { + if (cause instanceof HttpResponseException) { + int code = ((HttpResponseException) cause).getStatusCode(); + return shouldRetryCodeReason(code, null); + } else if (cause instanceof Retryable) { + Retryable retryable = (Retryable) cause; + return (idempotent && retryable.isRetryable()) ? RetryResult.RETRY : RetryResult.NO_RETRY; + } else if (cause instanceof IOException) { IOException ioException = (IOException) cause; return shouldRetryIOException(ioException); } diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java index e26d0cf558cf..095875081a09 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/RewindableContent.java @@ -146,7 +146,10 @@ public long getLength() { @Override void rewindTo(long offset) { Preconditions.checkArgument( - offset < size, "provided offset must be less than size (%d < %d)", offset, size); + 0 <= offset && offset <= size, + "provided offset must be within [0, size] (0 <= %s <= %s)", + offset, + size); this.readOffset = offset; } diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java index 6bd3bfc60068..216a43242203 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageException.java @@ -17,6 +17,7 @@ package com.google.cloud.storage; import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.http.HttpResponseException; import com.google.api.core.ApiFuture; import com.google.api.core.ApiFutures; import com.google.api.core.InternalApi; @@ -128,9 +129,24 @@ static BaseServiceException coalesce(Throwable t) { if (t.getCause() instanceof ApiException) { return asStorageException((ApiException) t.getCause()); } + if (t instanceof HttpResponseException) { + return fromHttpResponseException((HttpResponseException) t); + } + if (t.getCause() instanceof HttpResponseException) { + return fromHttpResponseException((HttpResponseException) t.getCause()); + } return getStorageException(t); } + /** + * Create a {@link StorageException} from an {@link HttpResponseException} preserving its HTTP + * status code (and, for a {@code GoogleJsonResponseException}, its JSON error code and reason), + * its message and the original exception as the cause. + */ + private static StorageException fromHttpResponseException(HttpResponseException e) { + return new StorageException((IOException) e); + } + static ApiFuture coalesceAsync(ApiFuture originalFuture) { return ApiFutures.catchingAsync( originalFuture, diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/DefaultRetryHandlingBehaviorTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/DefaultRetryHandlingBehaviorTest.java index 63e8af728773..8ef511820c0f 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/DefaultRetryHandlingBehaviorTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/DefaultRetryHandlingBehaviorTest.java @@ -347,6 +347,37 @@ enum ThrowableCategory { AUTH_RETRYABLE_TRUE(new RetryableException(true)), AUTH_RETRYABLE_FALSE(new RetryableException(false)), UNKNOWN_HOST_EXCEPTION(C.UNKNOWN_HOST_EXCEPTION), + STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_401( + new StorageException(0, "code 0 wrapping http 401", C.HTTP_401)), + STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_403( + new StorageException(0, "code 0 wrapping http 403", C.HTTP_403)), + STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_404( + new StorageException(0, "code 0 wrapping http 404", C.HTTP_404)), + STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_408( + new StorageException(0, "code 0 wrapping http 408", C.HTTP_409)), + STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_429( + new StorageException(0, "code 0 wrapping http 429", C.HTTP_429)), + STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_500( + new StorageException(0, "code 0 wrapping http 500", C.HTTP_500)), + STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_502( + new StorageException(0, "code 0 wrapping http 502", C.HTTP_502)), + STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_503( + new StorageException(0, "code 0 wrapping http 503", C.HTTP_503)), + STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_504( + new StorageException(0, "code 0 wrapping http 504", C.HTTP_504)), + COALESCED_HTTP_RESPONSE_EXCEPTION_401(StorageException.coalesce(C.HTTP_401)), + COALESCED_HTTP_RESPONSE_EXCEPTION_403(StorageException.coalesce(C.HTTP_403)), + COALESCED_HTTP_RESPONSE_EXCEPTION_404(StorageException.coalesce(C.HTTP_404)), + COALESCED_HTTP_RESPONSE_EXCEPTION_408(StorageException.coalesce(C.HTTP_409)), + COALESCED_HTTP_RESPONSE_EXCEPTION_429(StorageException.coalesce(C.HTTP_429)), + COALESCED_HTTP_RESPONSE_EXCEPTION_500(StorageException.coalesce(C.HTTP_500)), + COALESCED_HTTP_RESPONSE_EXCEPTION_502(StorageException.coalesce(C.HTTP_502)), + COALESCED_HTTP_RESPONSE_EXCEPTION_503(StorageException.coalesce(C.HTTP_503)), + COALESCED_HTTP_RESPONSE_EXCEPTION_504(StorageException.coalesce(C.HTTP_504)), + STORAGE_EXCEPTION_0_AUTH_RETRYABLE_TRUE( + new StorageException(0, "code 0 wrapping auth retryable", new RetryableException(true))), + STORAGE_EXCEPTION_0_AUTH_RETRYABLE_FALSE( + new StorageException(0, "code 0 wrapping auth retryable", new RetryableException(false))), ; private final Throwable throwable; @@ -1082,6 +1113,206 @@ private static ImmutableList getAllCases() { ThrowableCategory.UNKNOWN_HOST_EXCEPTION, HandlerCategory.NONIDEMPOTENT, ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_401, + HandlerCategory.IDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_401, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_403, + HandlerCategory.IDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_403, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_404, + HandlerCategory.IDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_404, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_408, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.DEFAULT_MORE_PERMISSIBLE), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_408, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_429, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.DEFAULT_MORE_PERMISSIBLE), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_429, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_500, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.DEFAULT_MORE_PERMISSIBLE), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_500, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_502, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.DEFAULT_MORE_PERMISSIBLE), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_502, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_503, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.DEFAULT_MORE_PERMISSIBLE), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_503, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_504, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.DEFAULT_MORE_PERMISSIBLE), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_HTTP_RESPONSE_EXCEPTION_504, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_401, + HandlerCategory.IDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_401, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_403, + HandlerCategory.IDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_403, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_404, + HandlerCategory.IDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_404, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_408, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_408, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.DEFAULT_MORE_STRICT), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_429, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_429, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.DEFAULT_MORE_STRICT), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_500, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_500, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.DEFAULT_MORE_STRICT), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_502, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_502, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.DEFAULT_MORE_STRICT), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_503, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_503, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.DEFAULT_MORE_STRICT), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_504, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.SAME), + new Case( + ThrowableCategory.COALESCED_HTTP_RESPONSE_EXCEPTION_504, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.DEFAULT_MORE_STRICT), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_AUTH_RETRYABLE_TRUE, + HandlerCategory.IDEMPOTENT, + ExpectRetry.YES, + Behavior.DEFAULT_MORE_PERMISSIBLE), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_AUTH_RETRYABLE_TRUE, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_AUTH_RETRYABLE_FALSE, + HandlerCategory.IDEMPOTENT, + ExpectRetry.NO, + Behavior.SAME), + new Case( + ThrowableCategory.STORAGE_EXCEPTION_0_AUTH_RETRYABLE_FALSE, + HandlerCategory.NONIDEMPOTENT, + ExpectRetry.NO, Behavior.SAME)) .build(); } diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ITMultipartUploadClientRetryTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ITMultipartUploadClientRetryTest.java new file mode 100644 index 000000000000..e9baed039a32 --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/ITMultipartUploadClientRetryTest.java @@ -0,0 +1,200 @@ +/* + * Copyright 2026 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; + +import static com.google.common.truth.Truth.assertThat; +import static io.grpc.netty.shaded.io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE; +import static io.grpc.netty.shaded.io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND; +import static io.grpc.netty.shaded.io.netty.handler.codec.http.HttpResponseStatus.OK; +import static io.grpc.netty.shaded.io.netty.handler.codec.http.HttpResponseStatus.SERVICE_UNAVAILABLE; +import static org.junit.Assert.assertThrows; + +import com.google.cloud.storage.FakeHttpServer.HttpRequestHandler; +import com.google.cloud.storage.it.runner.StorageITRunner; +import com.google.cloud.storage.it.runner.annotations.Backend; +import com.google.cloud.storage.it.runner.annotations.ParallelFriendly; +import com.google.cloud.storage.it.runner.annotations.SingleBackend; +import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadRequest; +import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadResponse; +import com.google.cloud.storage.multipartupload.model.CompletedMultipartUpload; +import com.google.cloud.storage.multipartupload.model.CompletedPart; +import com.google.cloud.storage.multipartupload.model.ListPartsRequest; +import com.google.cloud.storage.multipartupload.model.UploadPartRequest; +import com.google.cloud.storage.multipartupload.model.UploadPartResponse; +import com.google.common.collect.ImmutableList; +import io.grpc.netty.shaded.io.netty.buffer.ByteBuf; +import io.grpc.netty.shaded.io.netty.buffer.Unpooled; +import io.grpc.netty.shaded.io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.grpc.netty.shaded.io.netty.handler.codec.http.FullHttpRequest; +import io.grpc.netty.shaded.io.netty.handler.codec.http.FullHttpResponse; +import io.grpc.netty.shaded.io.netty.handler.codec.http.HttpRequest; +import io.grpc.netty.shaded.io.netty.handler.codec.http.HttpResponseStatus; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Verify {@link MultipartUploadClient} (as constructed via the public factory) retries retryable + * HTTP status codes returned by the XML API. + */ +@RunWith(StorageITRunner.class) +@SingleBackend(Backend.PROD) +@ParallelFriendly +public final class ITMultipartUploadClientRetryTest { + + private static final String ETAG = "\"af1ed31420542285653c803a34aa839a\""; + + @Test + public void uploadPart_503ThenSuccess_retriesWithIdenticalBody() throws Exception { + byte[] contentBytes = "hello world".getBytes(StandardCharsets.UTF_8); + int failures = 2; + AtomicInteger count = new AtomicInteger(0); + List bodies = new CopyOnWriteArrayList<>(); + HttpRequestHandler handler = + req -> { + bodies.add(readBody(req)); + if (count.incrementAndGet() <= failures) { + return xmlError(req, SERVICE_UNAVAILABLE, "ServiceUnavailable", "try again"); + } + DefaultFullHttpResponse resp = new DefaultFullHttpResponse(req.protocolVersion(), OK); + resp.headers().set("ETag", ETAG); + return resp; + }; + + try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) { + MultipartUploadClient client = + MultipartUploadClient.create( + MultipartUploadSettings.of(fakeHttpServer.getHttpStorageOptions())); + UploadPartResponse response = + client.uploadPart( + UploadPartRequest.builder() + .bucket("test-bucket") + .key("test-key") + .uploadId("test-upload-id") + .partNumber(1) + .build(), + RequestBody.of(ByteBuffer.wrap(contentBytes))); + + assertThat(response.eTag()).isEqualTo(ETAG); + assertThat(count.get()).isEqualTo(failures + 1); + for (byte[] body : bodies) { + assertThat(body).isEqualTo(contentBytes); + } + } + } + + @Test + public void completeMultipartUpload_503ThenSuccess_retries() throws Exception { + AtomicInteger count = new AtomicInteger(0); + HttpRequestHandler handler = + req -> { + if (count.incrementAndGet() == 1) { + return xmlError(req, SERVICE_UNAVAILABLE, "ServiceUnavailable", "try again"); + } + String xml = + "test-buckettest-key" + + "" + + ETAG + + ""; + ByteBuf buf = Unpooled.wrappedBuffer(xml.getBytes(StandardCharsets.UTF_8)); + DefaultFullHttpResponse resp = + new DefaultFullHttpResponse(req.protocolVersion(), OK, buf); + resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8"); + return resp; + }; + + try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) { + MultipartUploadClient client = + MultipartUploadClient.create( + MultipartUploadSettings.of(fakeHttpServer.getHttpStorageOptions())); + CompleteMultipartUploadResponse response = + client.completeMultipartUpload( + CompleteMultipartUploadRequest.builder() + .bucket("test-bucket") + .key("test-key") + .uploadId("test-upload-id") + .multipartUpload( + CompletedMultipartUpload.builder() + .parts( + ImmutableList.of( + CompletedPart.builder().partNumber(1).eTag("\"etag1\"").build())) + .build()) + .build()); + + assertThat(response.etag()).isEqualTo(ETAG); + assertThat(count.get()).isEqualTo(2); + } + } + + @Test + public void listParts_404_isNotRetried() throws Exception { + AtomicInteger count = new AtomicInteger(0); + HttpRequestHandler handler = + req -> { + count.incrementAndGet(); + return xmlError(req, NOT_FOUND, "NoSuchUpload", "The requested upload was not found."); + }; + + try (FakeHttpServer fakeHttpServer = FakeHttpServer.of(handler)) { + MultipartUploadClient client = + MultipartUploadClient.create( + MultipartUploadSettings.of(fakeHttpServer.getHttpStorageOptions())); + StorageException e = + assertThrows( + StorageException.class, + () -> + client.listParts( + ListPartsRequest.builder() + .bucket("test-bucket") + .key("test-key") + .uploadId("test-upload-id") + .build())); + + assertThat(e.getCode()).isEqualTo(404); + assertThat(e.getMessage()).contains("The requested upload was not found."); + assertThat(count.get()).isEqualTo(1); + } + } + + private static byte[] readBody(HttpRequest req) { + if (!(req instanceof FullHttpRequest)) { + return new byte[0]; + } + ByteBuf content = ((FullHttpRequest) req).content(); + byte[] bytes = new byte[content.readableBytes()]; + content.getBytes(content.readerIndex(), bytes); + return bytes; + } + + private static FullHttpResponse xmlError( + HttpRequest req, HttpResponseStatus status, String code, String message) { + String xml = + "" + + code + + "" + + message + + ""; + ByteBuf buf = Unpooled.wrappedBuffer(xml.getBytes(StandardCharsets.UTF_8)); + DefaultFullHttpResponse resp = new DefaultFullHttpResponse(req.protocolVersion(), status, buf); + resp.headers().set(CONTENT_TYPE, "application/xml; charset=utf-8"); + return resp; + } +} diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/MultipartUploadClientImplRetryTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/MultipartUploadClientImplRetryTest.java new file mode 100644 index 000000000000..5889d8655438 --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/MultipartUploadClientImplRetryTest.java @@ -0,0 +1,435 @@ +/* + * Copyright 2026 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; + +import static com.google.cloud.storage.TestUtils.assertAll; +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static org.junit.Assert.assertThrows; + +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import com.google.api.core.NanoClock; +import com.google.api.gax.retrying.RetrySettings; +import com.google.api.gax.rpc.FixedHeaderProvider; +import com.google.cloud.storage.Retrying.DefaultRetrier; +import com.google.cloud.storage.Retrying.RetryingDependencies; +import com.google.cloud.storage.multipartupload.model.AbortMultipartUploadRequest; +import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadRequest; +import com.google.cloud.storage.multipartupload.model.CompleteMultipartUploadResponse; +import com.google.cloud.storage.multipartupload.model.CompletedMultipartUpload; +import com.google.cloud.storage.multipartupload.model.CompletedPart; +import com.google.cloud.storage.multipartupload.model.CreateMultipartUploadRequest; +import com.google.cloud.storage.multipartupload.model.ListMultipartUploadsRequest; +import com.google.cloud.storage.multipartupload.model.ListMultipartUploadsResponse; +import com.google.cloud.storage.multipartupload.model.ListPartsRequest; +import com.google.cloud.storage.multipartupload.model.ListPartsResponse; +import com.google.cloud.storage.multipartupload.model.UploadPartRequest; +import com.google.cloud.storage.multipartupload.model.UploadPartResponse; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.function.UnaryOperator; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Hermetic tests verifying {@link MultipartUploadClientImpl} retries retryable HTTP status codes + * returned by the XML API, and replays identical request bodies on retry. + */ +public final class MultipartUploadClientImplRetryTest { + + private static final int MAX_ATTEMPTS = 6; + private static final int[] RETRYABLE_CODES = {408, 429, 500, 502, 503, 504}; + private static final int[] NON_RETRYABLE_CODES = {400, 403, 404}; + + private static final String ETAG = "\"af1ed31420542285653c803a34aa839a\""; + + @Rule public final TemporaryFolder temp = new TemporaryFolder(); + + @Test + public void listParts_retryableStatus_thenSuccess() throws Exception { + for (int code : RETRYABLE_CODES) { + ScriptedTransport transport = new ScriptedTransport(error(code), listPartsOk()); + ListPartsResponse response = client(transport).listParts(listPartsRequest()); + assertSucceededAfterOneRetry(code, transport, response); + assertThat(response.bucket()).isEqualTo("test-bucket"); + } + } + + @Test + public void abortMultipartUpload_retryableStatus_thenSuccess() throws Exception { + for (int code : RETRYABLE_CODES) { + ScriptedTransport transport = new ScriptedTransport(error(code), noContent()); + client(transport) + .abortMultipartUpload( + AbortMultipartUploadRequest.builder() + .bucket("test-bucket") + .key("test-key") + .uploadId("test-upload-id") + .build()); + assertWithMessage("requests for status %s", code).that(transport.requests).hasSize(2); + } + } + + @Test + public void completeMultipartUpload_retryableStatus_thenSuccess() throws Exception { + for (int code : RETRYABLE_CODES) { + ScriptedTransport transport = new ScriptedTransport(error(code), completeOk()); + CompleteMultipartUploadResponse response = + client(transport).completeMultipartUpload(completeRequest()); + assertSucceededAfterOneRetry(code, transport, response); + assertThat(response.etag()).isEqualTo(ETAG); + // the same request body must be sent on each attempt + assertThat(transport.requests.get(1).body).isEqualTo(transport.requests.get(0).body); + } + } + + @Test + public void uploadPart_retryableStatus_thenSuccess() throws Exception { + byte[] bytes = "hello world".getBytes(StandardCharsets.UTF_8); + for (int code : RETRYABLE_CODES) { + ScriptedTransport transport = new ScriptedTransport(error(code), uploadPartOk()); + UploadPartResponse response = + client(transport).uploadPart(uploadPartRequest(), RequestBody.of(ByteBuffer.wrap(bytes))); + assertSucceededAfterOneRetry(code, transport, response); + assertThat(response.eTag()).isEqualTo(ETAG); + assertThat(transport.requests.get(0).body).isEqualTo(bytes); + assertThat(transport.requests.get(1).body).isEqualTo(bytes); + } + } + + @Test + public void listMultipartUploads_retryableStatus_thenSuccess() throws Exception { + for (int code : RETRYABLE_CODES) { + ScriptedTransport transport = new ScriptedTransport(error(code), listUploadsOk()); + ListMultipartUploadsResponse response = + client(transport) + .listMultipartUploads( + ListMultipartUploadsRequest.builder().bucket("test-bucket").build()); + assertSucceededAfterOneRetry(code, transport, response); + assertThat(response.bucket()).isEqualTo("test-bucket"); + } + } + + @Test + public void createMultipartUpload_503_isNotRetried() throws Exception { + ScriptedTransport transport = new ScriptedTransport(error(503), createOk()); + StorageException e = + assertThrows( + StorageException.class, + () -> + client(transport) + .createMultipartUpload( + CreateMultipartUploadRequest.builder() + .bucket("test-bucket") + .key("test-key") + .build())); + assertAll( + () -> assertThat(e.getCode()).isEqualTo(503), + () -> assertThat(transport.requests).hasSize(1)); + } + + @Test + public void uploadPart_nonRetryableStatus_isNotRetried() throws Exception { + for (int code : NON_RETRYABLE_CODES) { + ScriptedTransport transport = new ScriptedTransport(error(code), uploadPartOk()); + StorageException e = + assertThrows( + StorageException.class, + () -> + client(transport) + .uploadPart( + uploadPartRequest(), RequestBody.of(ByteBuffer.wrap(new byte[] {1})))); + assertNotRetried(code, transport, e); + } + } + + @Test + public void listParts_nonRetryableStatus_isNotRetried() throws Exception { + for (int code : NON_RETRYABLE_CODES) { + ScriptedTransport transport = new ScriptedTransport(error(code), listPartsOk()); + StorageException e = + assertThrows( + StorageException.class, () -> client(transport).listParts(listPartsRequest())); + assertNotRetried(code, transport, e); + } + } + + @Test + public void uploadPart_singleByteBuffer_replaysIdenticalBytesAndChecksum() throws Exception { + byte[] bytes = DataGenerator.base64Characters().genBytes(64 * 1024 + 7); + assertReplaysIdentically(RequestBody.of(ByteBuffer.wrap(bytes)), bytes); + } + + @Test + public void uploadPart_multiByteBufferWithOffsets_replaysIdenticalBytesAndChecksum() + throws Exception { + byte[] a = DataGenerator.base64Characters().genBytes(1024); + byte[] b = DataGenerator.base64Characters().genBytes(2048); + byte[] c = DataGenerator.base64Characters().genBytes(512); + ByteBuffer bufA = ByteBuffer.wrap(a); + bufA.position(10); + ByteBuffer bufB = ByteBuffer.wrap(b); + bufB.position(100); + ByteBuffer bufC = ByteBuffer.wrap(c); + bufC.position(1); + + ByteArrayOutputStream expected = new ByteArrayOutputStream(); + expected.write(a, 10, a.length - 10); + expected.write(b, 100, b.length - 100); + expected.write(c, 1, c.length - 1); + + assertReplaysIdentically(RequestBody.of(bufA, bufB, bufC), expected.toByteArray()); + } + + @Test + public void uploadPart_path_replaysIdenticalBytesAndChecksum() throws Exception { + byte[] bytes = DataGenerator.base64Characters().genBytes(32 * 1024 + 5); + Path path = temp.newFile("part").toPath(); + Files.write(path, bytes); + assertReplaysIdentically(RequestBody.of(path), bytes); + } + + @Test + public void uploadPart_emptyPath_replaysIdenticalBytesAndChecksum() throws Exception { + Path path = temp.newFile("empty-part").toPath(); + assertReplaysIdentically(RequestBody.of(path), new byte[0]); + } + + @Test + public void uploadPart_alwaysRetryable_exhaustsBudget() throws Exception { + List responses = new ArrayList<>(); + for (int i = 0; i < MAX_ATTEMPTS + 2; i++) { + responses.add(error(503)); + } + ScriptedTransport transport = + new ScriptedTransport(responses.toArray(new MockLowLevelHttpResponse[0])); + StorageException e = + assertThrows( + StorageException.class, + () -> + client(transport) + .uploadPart( + uploadPartRequest(), RequestBody.of(ByteBuffer.wrap(new byte[] {1, 2})))); + String messages = TestUtils.messagesToText(e); + assertAll( + () -> assertThat(e.getCode()).isEqualTo(503), + () -> assertThat(transport.requests).hasSize(MAX_ATTEMPTS), + () -> assertThat(messages).contains("attempt budget")); + } + + private void assertReplaysIdentically(RequestBody body, byte[] expected) throws Exception { + ScriptedTransport transport = new ScriptedTransport(error(503), error(503), uploadPartOk()); + UploadPartResponse response = client(transport).uploadPart(uploadPartRequest(), body); + assertThat(response.eTag()).isEqualTo(ETAG); + assertThat(transport.requests).hasSize(3); + RecordedRequest first = transport.requests.get(0); + for (RecordedRequest r : transport.requests) { + assertAll( + () -> assertThat(r.body).isEqualTo(expected), + () -> assertThat(r.xGoogHash).isEqualTo(first.xGoogHash)); + } + } + + private static void assertSucceededAfterOneRetry( + int code, ScriptedTransport transport, Object response) { + assertWithMessage("response for status %s", code).that(response).isNotNull(); + assertWithMessage("requests for status %s", code).that(transport.requests).hasSize(2); + } + + private static void assertNotRetried(int code, ScriptedTransport transport, StorageException e) + throws Exception { + assertAll( + () -> assertThat(e.getCode()).isEqualTo(code), + () -> assertThat(e.getMessage()).contains("message for " + code), + () -> assertThat(transport.requests).hasSize(1)); + } + + private static MultipartUploadClientImpl client(ScriptedTransport transport) { + RetrySettings retrySettings = + RetrySettings.newBuilder() + .setMaxAttempts(MAX_ATTEMPTS) + .setInitialRetryDelayDuration(Duration.ofMillis(1)) + .setMaxRetryDelayDuration(Duration.ofMillis(5)) + .setRetryDelayMultiplier(1.0) + .setTotalTimeoutDuration(Duration.ofSeconds(30)) + .build(); + return new MultipartUploadClientImpl( + new DefaultRetrier( + UnaryOperator.identity(), + RetryingDependencies.simple(NanoClock.getDefaultClock(), retrySettings)), + new MultipartUploadHttpRequestManager( + transport.createRequestFactory(), + new XmlObjectParser(new XmlMapper()), + FixedHeaderProvider.create(ImmutableMap.of()), + URI.create("http://localhost/")), + new HttpRetryAlgorithmManager(DefaultStorageRetryStrategy.INSTANCE)); + } + + private static ListPartsRequest listPartsRequest() { + return ListPartsRequest.builder() + .bucket("test-bucket") + .key("test-key") + .uploadId("test-upload-id") + .build(); + } + + private static UploadPartRequest uploadPartRequest() { + return UploadPartRequest.builder() + .bucket("test-bucket") + .key("test-key") + .uploadId("test-upload-id") + .partNumber(1) + .build(); + } + + private static CompleteMultipartUploadRequest completeRequest() { + return CompleteMultipartUploadRequest.builder() + .bucket("test-bucket") + .key("test-key") + .uploadId("test-upload-id") + .multipartUpload( + CompletedMultipartUpload.builder() + .parts( + ImmutableList.of( + CompletedPart.builder().partNumber(1).eTag("\"etag1\"").build())) + .build()) + .build(); + } + + private static MockLowLevelHttpResponse error(int code) { + return new MockLowLevelHttpResponse() + .setStatusCode(code) + .setReasonPhrase("status " + code) + .setContentType("application/xml; charset=UTF-8") + .setContent( + "Code" + + code + + "message for " + + code + + ""); + } + + private static MockLowLevelHttpResponse xmlOk(String xml) { + return new MockLowLevelHttpResponse() + .setStatusCode(200) + .setContentType("application/xml; charset=UTF-8") + .setContent(xml); + } + + private static MockLowLevelHttpResponse noContent() { + return new MockLowLevelHttpResponse().setStatusCode(204); + } + + private static MockLowLevelHttpResponse createOk() { + return xmlOk( + "test-buckettest-key" + + "test-upload-id"); + } + + private static MockLowLevelHttpResponse listPartsOk() { + return xmlOk( + "test-buckettest-key" + + "test-upload-id"); + } + + private static MockLowLevelHttpResponse listUploadsOk() { + return xmlOk( + "test-bucket"); + } + + private static MockLowLevelHttpResponse completeOk() { + return xmlOk( + "test-buckettest-key" + + "" + + ETAG + + ""); + } + + private static MockLowLevelHttpResponse uploadPartOk() { + return new MockLowLevelHttpResponse().setStatusCode(200).addHeader("ETag", ETAG); + } + + private static final class RecordedRequest { + private final String method; + private final String url; + private final byte[] body; + private final @Nullable String xGoogHash; + + private RecordedRequest(String method, String url, byte[] body, @Nullable String xGoogHash) { + this.method = method; + this.url = url; + this.body = body; + this.xGoogHash = xGoogHash; + } + + @Override + public String toString() { + return method + " " + url + " (" + body.length + " bytes)"; + } + } + + /** A transport which returns a scripted sequence of responses and records each request. */ + private static final class ScriptedTransport extends MockHttpTransport { + private final Deque responses; + private final List requests = Collections.synchronizedList(new ArrayList<>()); + + private ScriptedTransport(MockLowLevelHttpResponse... responses) { + this.responses = new ArrayDeque<>(ImmutableList.copyOf(responses)); + } + + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + if (getStreamingContent() != null) { + getStreamingContent().writeTo(baos); + } + requests.add( + new RecordedRequest( + method, url, baos.toByteArray(), getFirstHeaderValue("x-goog-hash"))); + MockLowLevelHttpResponse next = responses.poll(); + if (next == null) { + throw new IllegalStateException("no more scripted responses for " + method + " " + url); + } + return next; + } + }; + } + } +} diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/RetryingTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/RetryingTest.java index 5c1eda96c3b0..d6b5b966042e 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/RetryingTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/RetryingTest.java @@ -20,6 +20,8 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; import com.google.api.core.NanoClock; import com.google.api.gax.grpc.GrpcStatusCode; import com.google.api.gax.retrying.RetrySettings; @@ -134,6 +136,96 @@ public void http() throws Exception { () -> assertThat(messages).containsMatch(UUID_PATTERN)); } + @Test + public void http503ThenSuccess_idempotent_retries() throws Exception { + AtomicInteger counter = new AtomicInteger(0); + String actual = + new DefaultRetrier(UnaryOperator.identity(), RETRYING_DEPENDENCIES) + .run( + DefaultStorageRetryStrategy.INSTANCE.getIdempotentHandler(), + () -> { + int i = counter.incrementAndGet(); + if (i < 3) { + throw httpResponseException(503, "Service Unavailable"); + } + return "ok"; + }, + Decoder.identity()); + assertAll( + () -> assertThat(actual).isEqualTo("ok"), () -> assertThat(counter.get()).isEqualTo(3)); + } + + @Test + public void http503_nonIdempotent_noRetry() throws Exception { + AtomicInteger counter = new AtomicInteger(0); + StorageException actual = + assertThrows( + StorageException.class, + () -> + new DefaultRetrier(UnaryOperator.identity(), RETRYING_DEPENDENCIES) + .run( + DefaultStorageRetryStrategy.INSTANCE.getNonidempotentHandler(), + () -> { + counter.incrementAndGet(); + throw httpResponseException(503, "Service Unavailable"); + }, + Decoder.identity())); + assertAll( + () -> assertThat(actual.getCode()).isEqualTo(503), + () -> assertThat(counter.get()).isEqualTo(1)); + } + + @Test + public void http404_idempotent_noRetry() throws Exception { + AtomicInteger counter = new AtomicInteger(0); + StorageException actual = + assertThrows( + StorageException.class, + () -> + new DefaultRetrier(UnaryOperator.identity(), RETRYING_DEPENDENCIES) + .run( + DefaultStorageRetryStrategy.INSTANCE.getIdempotentHandler(), + () -> { + counter.incrementAndGet(); + throw httpResponseException(404, "Not Found"); + }, + Decoder.identity())); + String messages = TestUtils.messagesToText(actual); + assertAll( + () -> assertThat(actual.getCode()).isEqualTo(404), + () -> assertThat(actual.getCause()).isInstanceOf(HttpResponseException.class), + () -> assertThat(counter.get()).isEqualTo(1), + () -> assertThat(messages).contains("Unretryable error")); + } + + @Test + public void http503_exhaustsBudget() throws Exception { + AtomicInteger counter = new AtomicInteger(0); + StorageException actual = + assertThrows( + StorageException.class, + () -> + new DefaultRetrier(UnaryOperator.identity(), RETRYING_DEPENDENCIES) + .run( + DefaultStorageRetryStrategy.INSTANCE.getIdempotentHandler(), + () -> { + counter.incrementAndGet(); + throw httpResponseException(503, "Service Unavailable"); + }, + Decoder.identity())); + String messages = TestUtils.messagesToText(actual); + assertAll( + () -> assertThat(actual.getCode()).isEqualTo(503), + () -> assertThat(counter.get()).isEqualTo(3), + () -> assertThat(messages).contains("Operation failed to complete within attempt budget")); + } + + private static HttpResponseException httpResponseException(int code, String statusMessage) { + return new HttpResponseException.Builder(code, statusMessage, new HttpHeaders()) + .setContent("" + statusMessage + "") + .build(); + } + private static ApiException apiException(Code code, String message) { return ApiExceptionFactory.createException(message, null, GrpcStatusCode.of(code), false); } diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/RewindablePathContentTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/RewindablePathContentTest.java new file mode 100644 index 000000000000..42f5c7b739cf --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/RewindablePathContentTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 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; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public final class RewindablePathContentTest { + + @Rule public final TemporaryFolder temp = new TemporaryFolder(); + + @Test + public void zeroByteFile_rewindToZero_succeeds() throws IOException { + Path empty = temp.newFile("empty").toPath(); + RewindableContent content = RewindableContent.of(empty); + + // first "attempt" + assertThat(writeAll(content)).isEmpty(); + // retry rewinds to 0, which must be allowed for a zero-byte file + content.rewindTo(0); + assertThat(content.getLength()).isEqualTo(0); + assertThat(writeAll(content)).isEmpty(); + } + + @Test + public void nonEmptyFile_rewindToSize_succeeds() throws IOException { + byte[] bytes = "abcdefghij".getBytes(StandardCharsets.UTF_8); + Path path = temp.newFile("ten").toPath(); + Files.write(path, bytes); + RewindableContent content = RewindableContent.of(path); + + content.rewindTo(bytes.length); + assertThat(content.getLength()).isEqualTo(0); + assertThat(writeAll(content)).isEmpty(); + } + + @Test + public void nonEmptyFile_rewindOutOfBounds_throws() throws IOException { + byte[] bytes = "abcdefghij".getBytes(StandardCharsets.UTF_8); + Path path = temp.newFile("ten").toPath(); + Files.write(path, bytes); + RewindableContent content = RewindableContent.of(path); + + assertThrows(IllegalArgumentException.class, () -> content.rewindTo(bytes.length + 1)); + assertThrows(IllegalArgumentException.class, () -> content.rewindTo(-1)); + } + + @Test + public void rewindToZero_afterFullWrite_replaysIdenticalBytes() throws IOException { + byte[] bytes = DataGenerator.base64Characters().genBytes(17 * 1024 + 3); + Path path = temp.newFile("data").toPath(); + Files.write(path, bytes); + RewindableContent content = RewindableContent.of(path); + + byte[] first = writeAll(content); + content.rewindTo(0); + byte[] second = writeAll(content); + + assertThat(first).isEqualTo(bytes); + assertThat(second).isEqualTo(bytes); + } + + private static byte[] writeAll(RewindableContent content) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (WritableByteChannel c = Channels.newChannel(baos)) { + content.writeTo(c); + } + return baos.toByteArray(); + } +} diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageExceptionHttpResponseExceptionTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageExceptionHttpResponseExceptionTest.java new file mode 100644 index 000000000000..eccd0f4de56b --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageExceptionHttpResponseExceptionTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2026 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; + +import static com.google.cloud.storage.TestUtils.assertAll; +import static com.google.common.truth.Truth.assertThat; + +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; +import com.google.cloud.BaseServiceException; +import java.io.IOException; +import java.io.UncheckedIOException; +import org.junit.Test; + +public final class StorageExceptionHttpResponseExceptionTest { + + private static final int[] RETRYABLE_CODES = {408, 429, 500, 502, 503, 504}; + private static final int[] NON_RETRYABLE_CODES = {400, 401, 403, 404, 409, 412}; + + @Test + public void coalesce_http503_preservesStatusMessageAndCause() throws Exception { + String body = "ServiceUnavailable"; + HttpResponseException hre = + new HttpResponseException.Builder(503, "Service Unavailable", new HttpHeaders()) + .setContent(body) + // a manually constructed Builder does not compute a message, set one mirroring what + // HttpResponseException.Builder(HttpResponse) produces: status line + body + .setMessage("503 Service Unavailable\n" + body) + .build(); + + BaseServiceException actual = StorageException.coalesce(hre); + + assertAll( + () -> assertThat(actual).isInstanceOf(StorageException.class), + () -> assertThat(actual.getCode()).isEqualTo(503), + () -> assertThat(actual.getReason()).isNull(), + () -> assertThat(actual.isRetryable()).isTrue(), + () -> assertThat(actual.getCause()).isSameInstanceAs(hre), + () -> assertThat(actual.getMessage()).isEqualTo(hre.getMessage()), + () -> assertThat(actual.getMessage()).contains("ServiceUnavailable")); + } + + @Test + public void coalesce_retryableStatusCodes() throws Exception { + for (int code : RETRYABLE_CODES) { + BaseServiceException actual = StorageException.coalesce(hre(code)); + assertAll( + () -> assertThat(actual.getCode()).isEqualTo(code), + () -> assertThat(actual.isRetryable()).isTrue()); + } + } + + @Test + public void coalesce_nonRetryableStatusCodes() throws Exception { + for (int code : NON_RETRYABLE_CODES) { + BaseServiceException actual = StorageException.coalesce(hre(code)); + assertAll( + () -> assertThat(actual.getCode()).isEqualTo(code), + () -> assertThat(actual.isRetryable()).isFalse()); + } + } + + @Test + public void coalesce_unwrapsHttpResponseExceptionCause() throws Exception { + HttpResponseException hre = hre(503); + BaseServiceException actual = StorageException.coalesce(new UncheckedIOException(hre)); + + assertAll( + () -> assertThat(actual.getCode()).isEqualTo(503), + () -> assertThat(actual.isRetryable()).isTrue(), + () -> assertThat(actual.getCause()).isSameInstanceAs(hre)); + } + + @Test + public void coalesce_plainIOException_stillUnknownCode() throws Exception { + IOException ioe = new IOException("boom"); + BaseServiceException actual = StorageException.coalesce(ioe); + + assertAll( + () -> assertThat(actual.getCode()).isEqualTo(BaseServiceException.UNKNOWN_CODE), + () -> assertThat(actual.getCause()).isSameInstanceAs(ioe)); + } + + @Test + public void coalesce_existingStorageException_identity() { + StorageException se = new StorageException(503, "unavailable"); + assertThat(StorageException.coalesce(se)).isSameInstanceAs(se); + } + + private static HttpResponseException hre(int code) { + return new HttpResponseException.Builder(code, "status " + code, new HttpHeaders()).build(); + } +}