From a6995e80186a9bba6269a354d0b30c8fd9d2b1c7 Mon Sep 17 00:00:00 2001 From: whowes Date: Sat, 12 Sep 2026 05:21:23 +0000 Subject: [PATCH] feat(gax): add resumable upload error classification and retry algorithm Introduce UploadCommand, UploadErrorClassifier, and UploadResultRetryAlgorithm to classify HTTP response codes and transport-level exceptions during resumable upload sessions into protocol error categories (TRANSIENT, RECOVERABLE, FATAL). Implements the classification order: 1. CancellationException is terminal (FATAL) and never retried. 2. ApiException with StatusCode.Code.UNKNOWN unwraps cause. GAX wraps unrecognized runtime throwables into Code.UNKNOWN, which carries a synthetic HTTP 500 transport code. Without this explicit step, local bugs and NPEs would be misclassified as transient 500s and retried indefinitely. Real wire 500 responses arrive with Code.INTERNAL and are TRANSIENT. 3. Table lookup on raw HTTP transport code (408, 429, 500, 502, 503, 504 are TRANSIENT; 400, 409, 412, 416 are RECOVERABLE; 401, 403, 404, 405, 410, 413, 415 are FATAL). Note that wire 408 and 412 both map to FAILED_PRECONDITION under HttpJsonStatusCode, but diverge based on raw HTTP transport code. 4. Plain I/O or timeout exceptions that bypassed ApiException wrapping are TRANSIENT; anything else unrecognized is FATAL. --- .../api/gax/rpc/ResumableUploadCommand.java | 43 +++ .../rpc/ResumableUploadErrorClassifier.java | 171 ++++++++++ .../ResumableUploadResultRetryAlgorithm.java | 70 +++++ .../ResumableUploadErrorClassifierTest.java | 294 ++++++++++++++++++ ...sumableUploadResultRetryAlgorithmTest.java | 122 ++++++++ 5 files changed, 700 insertions(+) create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCommand.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadErrorClassifier.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithm.java create mode 100644 sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadErrorClassifierTest.java create mode 100644 sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithmTest.java diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCommand.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCommand.java new file mode 100644 index 000000000000..cd78066c65e7 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCommand.java @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import org.jspecify.annotations.NullMarked; + +/** Enumeration of the supported resumable upload wire commands. */ +@NullMarked +enum ResumableUploadCommand { + START, + UPLOAD, + FINALIZE, + UPLOAD_FINALIZE, + QUERY, + CANCEL +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadErrorClassifier.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadErrorClassifier.java new file mode 100644 index 000000000000..2243fa381f55 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadErrorClassifier.java @@ -0,0 +1,171 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import org.jspecify.annotations.NullMarked; + +/** + * Classifies exceptions encountered during resumable upload commands to determine whether/how to + * recover. + */ +@NullMarked +final class ResumableUploadErrorClassifier { + + enum Category { + TRANSIENT, + RECOVERABLE, + FATAL + } + + private static final ImmutableMap HTTP_STATUS_MAP = + ImmutableMap.builder() + .put(408, Category.TRANSIENT) + .put(429, Category.TRANSIENT) + .put(500, Category.TRANSIENT) + .put(502, Category.TRANSIENT) + .put(503, Category.TRANSIENT) + .put(504, Category.TRANSIENT) + .put(400, Category.RECOVERABLE) + .put(409, Category.RECOVERABLE) + .put(412, Category.RECOVERABLE) + .put(416, Category.RECOVERABLE) + .put(401, Category.FATAL) + .put(403, Category.FATAL) + .put(404, Category.FATAL) + .put(405, Category.FATAL) + .put(410, Category.FATAL) + .put(413, Category.FATAL) + .put(415, Category.FATAL) + .build(); + + private ResumableUploadErrorClassifier() {} + + /** + * Classifies an exception for the given upload command according to protocol rules. + * + * @param t the error to classify + * @param command the upload command that produced the error + * @return the classified error category + */ + static Category classify(Throwable t, ResumableUploadCommand command) { + Objects.requireNonNull(t, "t must not be null"); + Objects.requireNonNull(command, "command must not be null"); + + // Cancellation is terminal and never retryable. + if (t instanceof CancellationException) { + return Category.FATAL; + } + + if (t instanceof ApiException) { + ApiException apiException = (ApiException) t; + + // GAX assigns a synthetic HTTP 500 to Code.UNKNOWN. Only retry if caused + // by an IOException; client bugs (NPE, etc.) must remain FATAL. + // Actual 500s from the wire arrive with Code.INTERNAL. + if (apiException.getStatusCode().getCode() == StatusCode.Code.UNKNOWN) { + Throwable cause = apiException.getCause(); + if (cause instanceof IOException) { + return Category.TRANSIENT; + } + return Category.FATAL; + } + + // Server rejections (final/cancelled on non-2xx) and protocol violations map to + // FAILED_PRECONDITION and are terminal. HTTP 408 and 412 also map to FAILED_PRECONDITION in + // HttpJsonStatusCode but have distinct protocol categories. + Object transportCode = apiException.getStatusCode().getTransportCode(); + if (apiException.getStatusCode().getCode() == StatusCode.Code.FAILED_PRECONDITION + && !Integer.valueOf(408).equals(transportCode) + && !Integer.valueOf(412).equals(transportCode)) { + return Category.FATAL; + } + + // Status table lookup on raw HTTP transport code. + if (transportCode instanceof Integer) { + Category category = HTTP_STATUS_MAP.get(transportCode); + if (category == Category.RECOVERABLE && !isRecoverableCommand(command)) { + return Category.FATAL; + } + if (category != null) { + return category; + } + } + return Category.FATAL; + } + + // Plain I/O or timeout exceptions that bypassed ApiException wrapping are transient. + if (t instanceof IOException) { + return Category.TRANSIENT; + } + + // Unrecognized errors fail the upload immediately. + return Category.FATAL; + } + + /** + * Classifies a missing upload status response header according to the wire command. + * + * @param command the upload command that received a response lacking the status header + * @return the classified error category + */ + static Category classifyMissingStatusHeader(ResumableUploadCommand command) { + Objects.requireNonNull(command, "command must not be null"); + switch (command) { + case START: + return Category.TRANSIENT; + case UPLOAD: + case FINALIZE: + case UPLOAD_FINALIZE: + return Category.RECOVERABLE; + case QUERY: + case CANCEL: + default: + return Category.FATAL; + } + } + + private static boolean isRecoverableCommand(ResumableUploadCommand command) { + switch (command) { + case UPLOAD: + case FINALIZE: + case UPLOAD_FINALIZE: + return true; + case START: + case QUERY: + case CANCEL: + default: + return false; + } + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithm.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithm.java new file mode 100644 index 000000000000..ab35c0e3a9f6 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithm.java @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import static com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category.TRANSIENT; + +import com.google.api.gax.retrying.BasicResultRetryAlgorithm; +import com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * An adapter that integrates {@link ResumableUploadErrorClassifier} into GAX retrying machinery. + * + *

Only transient errors should retry with an identical request; other recoverable errors will + * need to query the upload server to determine the appropriate next request. + * + * @param the response type of the upload attempt + */ +@NullMarked +final class ResumableUploadResultRetryAlgorithm + extends BasicResultRetryAlgorithm { + + private final ResumableUploadCommand command; + + ResumableUploadResultRetryAlgorithm(ResumableUploadCommand command) { + this.command = Objects.requireNonNull(command); + } + + @Override + public boolean shouldRetry( + @Nullable Throwable previousThrowable, @Nullable ResponseT previousResponse) { + // Successful commands (null throwable) and cancellations should not retry. + if (previousThrowable == null || previousThrowable instanceof CancellationException) { + return false; + } + Category category = ResumableUploadErrorClassifier.classify(previousThrowable, command); + // Transient errors are retried directly with the identical request. + return category == TRANSIENT; + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadErrorClassifierTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadErrorClassifierTest.java new file mode 100644 index 000000000000..5bdb7f55b456 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadErrorClassifierTest.java @@ -0,0 +1,294 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import static com.google.api.gax.rpc.ResumableUploadCommand.CANCEL; +import static com.google.api.gax.rpc.ResumableUploadCommand.FINALIZE; +import static com.google.api.gax.rpc.ResumableUploadCommand.QUERY; +import static com.google.api.gax.rpc.ResumableUploadCommand.START; +import static com.google.api.gax.rpc.ResumableUploadCommand.UPLOAD; +import static com.google.api.gax.rpc.ResumableUploadCommand.UPLOAD_FINALIZE; +import static com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category.FATAL; +import static com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category.RECOVERABLE; +import static com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category.TRANSIENT; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.api.gax.rpc.StatusCode.Code; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.concurrent.CancellationException; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class ResumableUploadErrorClassifierTest { + + private static StatusCode statusCode(int httpStatus, Code code) { + return new StatusCode() { + @Override + public Code getCode() { + return code; + } + + @Override + public Object getTransportCode() { + return httpStatus; + } + }; + } + + private static ApiException createApiException(int httpStatus, Code code) { + return createApiException(httpStatus, code, null); + } + + private static ApiException createApiException( + int httpStatus, Code code, @Nullable Throwable cause) { + return ApiExceptionFactory.createException( + "HTTP " + httpStatus, cause, statusCode(httpStatus, code), false); + } + + @ParameterizedTest(name = "HTTP {0} is TRANSIENT on {1}") + @CsvSource({ + "408, START", + "408, UPLOAD", + "408, QUERY", + "429, START", + "429, UPLOAD", + "429, QUERY", + "500, START", + "500, UPLOAD", + "500, QUERY", + "502, UPLOAD", + "503, START", + "503, UPLOAD", + "503, QUERY", + "504, UPLOAD", + }) + void testTransientHttpErrors_areTransientAcrossCommands( + int httpStatus, ResumableUploadCommand command) { + Code code = (httpStatus == 503) ? Code.UNAVAILABLE : Code.INTERNAL; + ApiException exception = createApiException(httpStatus, code); + assertThat(ResumableUploadErrorClassifier.classify(exception, command)).isEqualTo(TRANSIENT); + } + + @Test + void testIoExceptions_areTransientAcrossCommands() { + assertThat(ResumableUploadErrorClassifier.classify(new IOException("broken pipe"), UPLOAD)) + .isEqualTo(TRANSIENT); + assertThat( + ResumableUploadErrorClassifier.classify( + new SocketTimeoutException("connect timeout"), START)) + .isEqualTo(TRANSIENT); + assertThat(ResumableUploadErrorClassifier.classify(new IOException("connection reset"), QUERY)) + .isEqualTo(TRANSIENT); + } + + @Test + void testSyntheticUnknownWithWrappedIOException_isTransient() { + // When GAX maps an IOException into Code.UNKNOWN with synthetic 500, it remains TRANSIENT. + ApiException wrappedIo = + createApiException(500, Code.UNKNOWN, new IOException("connection reset")); + assertThat(ResumableUploadErrorClassifier.classify(wrappedIo, UPLOAD)).isEqualTo(TRANSIENT); + + ApiException wrappedTimeout = + createApiException(500, Code.UNKNOWN, new SocketTimeoutException("read timeout")); + assertThat(ResumableUploadErrorClassifier.classify(wrappedTimeout, START)).isEqualTo(TRANSIENT); + } + + @ParameterizedTest(name = "HTTP {0} on {1} is RECOVERABLE") + @CsvSource({ + "400, UPLOAD", + "400, FINALIZE", + "400, UPLOAD_FINALIZE", + "409, UPLOAD", + "409, FINALIZE", + "409, UPLOAD_FINALIZE", + "412, UPLOAD", + "412, FINALIZE", + "412, UPLOAD_FINALIZE", + "416, UPLOAD", + "416, FINALIZE", + "416, UPLOAD_FINALIZE", + }) + void testRecoverableErrors_areRecoverableOnChunkUploadCommands( + int httpStatus, ResumableUploadCommand command) { + ApiException exception = createApiException(httpStatus, Code.INVALID_ARGUMENT); + assertThat(ResumableUploadErrorClassifier.classify(exception, command)).isEqualTo(RECOVERABLE); + } + + @ParameterizedTest(name = "HTTP {0} on non-chunk command {1} must be FATAL") + @CsvSource({ + // On START: No session URL exists yet; cannot query status to recover offset. + "400, START", + "409, START", + "412, START", + "416, START", + // On QUERY: Already in recovery; 4xx on status query must not loop back into recovery. + "400, QUERY", + "409, QUERY", + "412, QUERY", + "416, QUERY", + // On CANCEL: Cancellation failure cannot be recovered by querying byte offsets. + "400, CANCEL", + "409, CANCEL", + "412, CANCEL", + "416, CANCEL", + }) + void testRecoverableCodes_areFatalOnNonChunkCommands( + int httpStatus, ResumableUploadCommand command) { + ApiException exception = createApiException(httpStatus, Code.INVALID_ARGUMENT); + assertThat(ResumableUploadErrorClassifier.classify(exception, command)).isEqualTo(FATAL); + } + + @Test + void testMissingStatusHeader_commandSpecificClassification() { + // Missing header on START must be retried directly (TRANSIENT). + assertThat(ResumableUploadErrorClassifier.classifyMissingStatusHeader(START)) + .isEqualTo(TRANSIENT); + + // Missing header during chunk transmission must trigger query/recovery. + assertThat(ResumableUploadErrorClassifier.classifyMissingStatusHeader(UPLOAD)) + .isEqualTo(RECOVERABLE); + assertThat(ResumableUploadErrorClassifier.classifyMissingStatusHeader(FINALIZE)) + .isEqualTo(RECOVERABLE); + assertThat(ResumableUploadErrorClassifier.classifyMissingStatusHeader(UPLOAD_FINALIZE)) + .isEqualTo(RECOVERABLE); + + // Missing header on query or cancel is an unrecoverable protocol error (FATAL). + assertThat(ResumableUploadErrorClassifier.classifyMissingStatusHeader(QUERY)).isEqualTo(FATAL); + assertThat(ResumableUploadErrorClassifier.classifyMissingStatusHeader(CANCEL)).isEqualTo(FATAL); + } + + @Test + void test408And412_divergeInClassificationDespiteSameGaxCode() { + // Both 408 and 412 map to Code.FAILED_PRECONDITION in HttpJsonStatusCode. + ApiException error408 = createApiException(408, Code.FAILED_PRECONDITION); + ApiException error412 = createApiException(412, Code.FAILED_PRECONDITION); + + assertThat(error408.getStatusCode().getCode()).isEqualTo(error412.getStatusCode().getCode()); + + // On chunk upload: 408 is TRANSIENT, 412 is RECOVERABLE. + assertThat(ResumableUploadErrorClassifier.classify(error408, UPLOAD)).isEqualTo(TRANSIENT); + assertThat(ResumableUploadErrorClassifier.classify(error412, UPLOAD)).isEqualTo(RECOVERABLE); + + // On START: 408 is TRANSIENT, but 412 is FATAL (cannot recover). + assertThat(ResumableUploadErrorClassifier.classify(error408, START)).isEqualTo(TRANSIENT); + assertThat(ResumableUploadErrorClassifier.classify(error412, START)).isEqualTo(FATAL); + } + + @Test + void testSynthetic500FromWrappedRuntimeException_isFatalWhereasReal500IsTransient() { + // Real wire 500 arrives with Code.INTERNAL. + ApiException real500 = createApiException(500, Code.INTERNAL); + assertThat(ResumableUploadErrorClassifier.classify(real500, UPLOAD)).isEqualTo(TRANSIENT); + + // Synthetic 500 from an unrecognized runtime exception arrives with Code.UNKNOWN. + // It must be FATAL to prevent endless retries on client-side bugs or NPEs. + ApiException synthetic500 = + ApiExceptionFactory.createException( + new IllegalStateException("local bug"), + statusCode(500, Code.UNKNOWN), + /* retryable= */ false); + assertThat(ResumableUploadErrorClassifier.classify(synthetic500, UPLOAD)).isEqualTo(FATAL); + + ApiException synthetic500Npe = + ApiExceptionFactory.createException( + new NullPointerException("null reference"), + statusCode(500, Code.UNKNOWN), + /* retryable= */ false); + assertThat(ResumableUploadErrorClassifier.classify(synthetic500Npe, UPLOAD)).isEqualTo(FATAL); + } + + @ParameterizedTest(name = "Client error {0} is FATAL") + @ValueSource(ints = {401, 403, 404, 405, 410, 413, 415, 418, 505}) + void testFatalClientErrorsAndUnmappedCodes_areFatal(int httpStatus) { + ApiException exception = createApiException(httpStatus, Code.INVALID_ARGUMENT); + assertThat(ResumableUploadErrorClassifier.classify(exception, UPLOAD)).isEqualTo(FATAL); + } + + @Test + void testServerRejection_failedPreconditionOverridesHttpStatus() { + // When the server rejects an upload (e.g. 503 with X-Goog-Upload-Status: final or canceled), + // the transport maps it to Code.FAILED_PRECONDITION, which must be FATAL regardless of the 503 + // HTTP status. + ApiException rejectionOn503 = createApiException(503, Code.FAILED_PRECONDITION); + assertThat(ResumableUploadErrorClassifier.classify(rejectionOn503, UPLOAD)).isEqualTo(FATAL); + assertThat(ResumableUploadErrorClassifier.classify(rejectionOn503, START)).isEqualTo(FATAL); + } + + @Test + void testSentinelsAndUnrecognizedThrowables_areFatal() { + // Cancellation is always fatal. + assertThat( + ResumableUploadErrorClassifier.classify(new CancellationException("cancelled"), UPLOAD)) + .isEqualTo(FATAL); + + // Unwrapped runtime exceptions default to FATAL. + assertThat( + ResumableUploadErrorClassifier.classify( + new NullPointerException("unexpected null"), UPLOAD)) + .isEqualTo(FATAL); + assertThat( + ResumableUploadErrorClassifier.classify(new IllegalStateException("bad state"), UPLOAD)) + .isEqualTo(FATAL); + + // Null inputs throw NullPointerException. + assertThrows( + NullPointerException.class, () -> ResumableUploadErrorClassifier.classify(null, UPLOAD)); + assertThrows( + NullPointerException.class, + () -> ResumableUploadErrorClassifier.classify(new IOException("io"), null)); + assertThrows( + NullPointerException.class, + () -> ResumableUploadErrorClassifier.classifyMissingStatusHeader(null)); + + // Sentinel exceptions without wire transport code (e.g. watchdog timeout, client validation) + // are FATAL. + StatusCode sentinelWithoutTransportCode = + new StatusCode() { + @Override + public Code getCode() { + return Code.DEADLINE_EXCEEDED; + } + + @Override + public @Nullable Object getTransportCode() { + return null; + } + }; + ApiException watchdogDeadline = + ApiExceptionFactory.createException( + "Session timeout", null, sentinelWithoutTransportCode, false); + assertThat(ResumableUploadErrorClassifier.classify(watchdogDeadline, UPLOAD)).isEqualTo(FATAL); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithmTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithmTest.java new file mode 100644 index 000000000000..eb4f5edcf81d --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadResultRetryAlgorithmTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import static com.google.api.gax.rpc.ResumableUploadCommand.UPLOAD; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.api.gax.rpc.StatusCode.Code; +import java.io.IOException; +import java.util.concurrent.CancellationException; +import org.junit.jupiter.api.Test; + +class ResumableUploadResultRetryAlgorithmTest { + + private static ApiException createApiException(int httpStatus, Code code) { + return ApiExceptionFactory.createException( + "HTTP " + httpStatus, + null, + new StatusCode() { + @Override + public Code getCode() { + return code; + } + + @Override + public Object getTransportCode() { + return httpStatus; + } + }, + false); + } + + @Test + void testConstructor_nullCommandThrows() { + assertThrows(NullPointerException.class, () -> new ResumableUploadResultRetryAlgorithm<>(null)); + } + + @Test + void testShouldRetry_nullThrowableReturnsFalse() { + ResumableUploadResultRetryAlgorithm algorithm = + new ResumableUploadResultRetryAlgorithm<>(UPLOAD); + + assertThat(algorithm.shouldRetry(null, "success")).isFalse(); + } + + @Test + void testShouldRetry_cancellationExceptionReturnsFalse() { + ResumableUploadResultRetryAlgorithm algorithm = + new ResumableUploadResultRetryAlgorithm<>(UPLOAD); + + CancellationException cancellationException = new CancellationException("cancelled"); + assertThat(algorithm.shouldRetry(cancellationException, null)).isFalse(); + } + + @Test + void testShouldRetry_transientErrorReturnsTrue() { + ResumableUploadResultRetryAlgorithm algorithm = + new ResumableUploadResultRetryAlgorithm<>(UPLOAD); + + assertThat(algorithm.shouldRetry(new IOException("connection reset"), null)).isTrue(); + ApiException transient503 = createApiException(503, Code.UNAVAILABLE); + assertThat(algorithm.shouldRetry(transient503, null)).isTrue(); + } + + @Test + void testShouldRetry_recoverableErrorReturnsFalse() { + // In G1/G4, only TRANSIENT is retried. RECOVERABLE errors fail the chunk attempt + // to trigger the query-status recovery loop rather than retrying blindly. + ResumableUploadResultRetryAlgorithm algorithm = + new ResumableUploadResultRetryAlgorithm<>(UPLOAD); + + ApiException recoverable412 = createApiException(412, Code.FAILED_PRECONDITION); + assertThat(algorithm.shouldRetry(recoverable412, null)).isFalse(); + } + + @Test + void testShouldRetry_fatalErrorReturnsFalse() { + ResumableUploadResultRetryAlgorithm algorithm = + new ResumableUploadResultRetryAlgorithm<>(UPLOAD); + + ApiException fatal403 = createApiException(403, Code.PERMISSION_DENIED); + assertThat(algorithm.shouldRetry(fatal403, null)).isFalse(); + } + + @Test + void testShouldRetry_serverRejectionReturnsFalse() { + ResumableUploadResultRetryAlgorithm algorithm = + new ResumableUploadResultRetryAlgorithm<>(UPLOAD); + + // 503 with X-Goog-Upload-Status: final or cancelled is mapped to Code.FAILED_PRECONDITION + ApiException rejected503 = createApiException(503, Code.FAILED_PRECONDITION); + assertThat(algorithm.shouldRetry(rejected503, null)).isFalse(); + } +}