diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java
new file mode 100644
index 000000000000..70b8c73a71d8
--- /dev/null
+++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithm.java
@@ -0,0 +1,137 @@
+/*
+ * 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.resumable;
+
+import com.google.api.core.BetaApi;
+import com.google.api.core.InternalApi;
+import com.google.api.gax.retrying.BasicResultRetryAlgorithm;
+import com.google.api.gax.retrying.ResultRetryAlgorithm;
+import com.google.api.gax.retrying.RetryingContext;
+import com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.StatusCode;
+import com.google.common.collect.ImmutableSet;
+import java.io.IOException;
+import java.util.Set;
+import java.util.concurrent.CancellationException;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Implementation of {@link ResultRetryAlgorithm} for resumable uploads based on the Unified
+ * Resumable Upload Protocol specification.
+ *
+ *
Differentiates between:
+ *
+ *
+ * - Category 1 (Transient): Retriable without modification (e.g. UNAVAILABLE,
+ * DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, network I/O errors).
+ *
- Category 2 (Recoverable): Retriable with modification (e.g. OUT_OF_RANGE,
+ * INVALID_ARGUMENT, FAILED_PRECONDITION, ABORTED, INTERNAL) where the upload offset must be
+ * recovered via queryStatus.
+ *
- Category 3 (Terminal): Fatal errors (e.g. NOT_FOUND, UNAUTHENTICATED,
+ * PERMISSION_DENIED, CancellationException) which abort immediately.
+ *
+ */
+@BetaApi
+@InternalApi
+@NullMarked
+public class ResumableUploadResultRetryAlgorithm
+ extends BasicResultRetryAlgorithm {
+
+ private static final Set DEFAULT_RETRYABLE_CODES =
+ ImmutableSet.of(
+ // Category 1: Transient errors
+ StatusCode.Code.UNAVAILABLE,
+ StatusCode.Code.DEADLINE_EXCEEDED,
+ StatusCode.Code.RESOURCE_EXHAUSTED,
+ // Category 2: Recoverable errors (offset mismatch, precondition, missing header)
+ StatusCode.Code.OUT_OF_RANGE,
+ StatusCode.Code.INVALID_ARGUMENT,
+ StatusCode.Code.FAILED_PRECONDITION,
+ StatusCode.Code.ABORTED,
+ StatusCode.Code.INTERNAL);
+
+ private final Set retryableCodes;
+
+ public static ResumableUploadResultRetryAlgorithm create() {
+ return new ResumableUploadResultRetryAlgorithm<>(DEFAULT_RETRYABLE_CODES);
+ }
+
+ public static ResumableUploadResultRetryAlgorithm create(
+ Set retryableCodes) {
+ return new ResumableUploadResultRetryAlgorithm<>(retryableCodes);
+ }
+
+ public ResumableUploadResultRetryAlgorithm() {
+ this(DEFAULT_RETRYABLE_CODES);
+ }
+
+ public ResumableUploadResultRetryAlgorithm(Set retryableCodes) {
+ this.retryableCodes = ImmutableSet.copyOf(retryableCodes);
+ }
+
+ public Set getRetryableCodes() {
+ return retryableCodes;
+ }
+
+ @Override
+ public boolean shouldRetry(
+ @Nullable Throwable previousThrowable, @Nullable ResponseT previousResponse) {
+ if (previousThrowable == null) {
+ return false;
+ }
+ if (previousThrowable instanceof CancellationException) {
+ return false;
+ }
+ if (previousThrowable instanceof ApiException) {
+ StatusCode.Code code = ((ApiException) previousThrowable).getStatusCode().getCode();
+ return retryableCodes.contains(code);
+ }
+ if (previousThrowable instanceof IOException) {
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean shouldRetry(
+ RetryingContext context,
+ @Nullable Throwable previousThrowable,
+ @Nullable ResponseT previousResponse) {
+ if (context.getRetryableCodes() != null) {
+ if (previousThrowable instanceof ApiException) {
+ return context
+ .getRetryableCodes()
+ .contains(((ApiException) previousThrowable).getStatusCode().getCode());
+ }
+ }
+ return shouldRetry(previousThrowable, previousResponse);
+ }
+}
diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithmTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithmTest.java
new file mode 100644
index 000000000000..d4eae7f9c1de
--- /dev/null
+++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumable/ResumableUploadResultRetryAlgorithmTest.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.resumable;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.api.gax.rpc.AbortedException;
+import com.google.api.gax.rpc.DeadlineExceededException;
+import com.google.api.gax.rpc.FailedPreconditionException;
+import com.google.api.gax.rpc.InternalException;
+import com.google.api.gax.rpc.InvalidArgumentException;
+import com.google.api.gax.rpc.NotFoundException;
+import com.google.api.gax.rpc.OutOfRangeException;
+import com.google.api.gax.rpc.PermissionDeniedException;
+import com.google.api.gax.rpc.ResourceExhaustedException;
+import com.google.api.gax.rpc.StatusCode;
+import com.google.api.gax.rpc.UnauthenticatedException;
+import com.google.api.gax.rpc.UnavailableException;
+import com.google.api.gax.rpc.testing.FakeCallContext;
+import com.google.api.gax.rpc.testing.FakeStatusCode;
+import com.google.common.collect.ImmutableSet;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.concurrent.CancellationException;
+import org.junit.jupiter.api.Test;
+
+class ResumableUploadResultRetryAlgorithmTest {
+
+ private final ResumableUploadResultRetryAlgorithm algorithm =
+ ResumableUploadResultRetryAlgorithm.create();
+
+ @Test
+ void testCategory1TransientErrors_shouldRetry() {
+ // 503 Unavailable
+ UnavailableException unavailable =
+ new UnavailableException(
+ "unavailable", null, FakeStatusCode.of(StatusCode.Code.UNAVAILABLE), false);
+ assertThat(algorithm.shouldRetry(unavailable, null)).isTrue();
+
+ // 504 DeadlineExceeded
+ DeadlineExceededException deadlineExceeded =
+ new DeadlineExceededException(
+ "deadline exceeded", null, FakeStatusCode.of(StatusCode.Code.DEADLINE_EXCEEDED), false);
+ assertThat(algorithm.shouldRetry(deadlineExceeded, null)).isTrue();
+
+ // 429 ResourceExhausted
+ ResourceExhaustedException resourceExhausted =
+ new ResourceExhaustedException(
+ "quota exceeded", null, FakeStatusCode.of(StatusCode.Code.RESOURCE_EXHAUSTED), false);
+ assertThat(algorithm.shouldRetry(resourceExhausted, null)).isTrue();
+
+ // Network / Socket I/O exception
+ IOException ioException = new IOException("connection reset by peer");
+ assertThat(algorithm.shouldRetry(ioException, null)).isTrue();
+ }
+
+ @Test
+ void testCategory2RecoverableErrors_shouldRetry() {
+ // 416 OutOfRange (chunk offset mismatch)
+ OutOfRangeException outOfRange =
+ new OutOfRangeException(
+ "out of range", null, FakeStatusCode.of(StatusCode.Code.OUT_OF_RANGE), false);
+ assertThat(algorithm.shouldRetry(outOfRange, null)).isTrue();
+
+ // 400 InvalidArgument (chunk offset / payload mismatch)
+ InvalidArgumentException invalidArgument =
+ new InvalidArgumentException(
+ "invalid argument", null, FakeStatusCode.of(StatusCode.Code.INVALID_ARGUMENT), false);
+ assertThat(algorithm.shouldRetry(invalidArgument, null)).isTrue();
+
+ // 412 FailedPrecondition
+ FailedPreconditionException failedPrecondition =
+ new FailedPreconditionException(
+ "failed precondition",
+ null,
+ FakeStatusCode.of(StatusCode.Code.FAILED_PRECONDITION),
+ false);
+ assertThat(algorithm.shouldRetry(failedPrecondition, null)).isTrue();
+
+ // 409 Aborted (conflict)
+ AbortedException aborted =
+ new AbortedException("aborted", null, FakeStatusCode.of(StatusCode.Code.ABORTED), false);
+ assertThat(algorithm.shouldRetry(aborted, null)).isTrue();
+
+ // 500 / Protocol Internal error (missing status headers)
+ InternalException internal =
+ new InternalException(
+ "internal protocol error", null, FakeStatusCode.of(StatusCode.Code.INTERNAL), false);
+ assertThat(algorithm.shouldRetry(internal, null)).isTrue();
+ }
+
+ @Test
+ void testCategory3FatalTerminalErrors_shouldNotRetry() {
+ // 404 NotFound (session expired or invalid)
+ NotFoundException notFound =
+ new NotFoundException(
+ "session not found", null, FakeStatusCode.of(StatusCode.Code.NOT_FOUND), false);
+ assertThat(algorithm.shouldRetry(notFound, null)).isFalse();
+
+ // 401 Unauthenticated
+ UnauthenticatedException unauthenticated =
+ new UnauthenticatedException(
+ "unauthenticated", null, FakeStatusCode.of(StatusCode.Code.UNAUTHENTICATED), false);
+ assertThat(algorithm.shouldRetry(unauthenticated, null)).isFalse();
+
+ // 403 PermissionDenied
+ PermissionDeniedException permissionDenied =
+ new PermissionDeniedException(
+ "permission denied", null, FakeStatusCode.of(StatusCode.Code.PERMISSION_DENIED), false);
+ assertThat(algorithm.shouldRetry(permissionDenied, null)).isFalse();
+
+ // Cancellation
+ CancellationException cancellation = new CancellationException("cancelled");
+ assertThat(algorithm.shouldRetry(cancellation, null)).isFalse();
+
+ // Generic RuntimeException
+ RuntimeException runtime = new RuntimeException("unexpected");
+ assertThat(algorithm.shouldRetry(runtime, null)).isFalse();
+ }
+
+ @Test
+ void testRetryingContextOverride() {
+ FakeCallContext contextWithEmptyCodes =
+ FakeCallContext.createDefault().withRetryableCodes(Collections.emptySet());
+
+ UnavailableException unavailable =
+ new UnavailableException(
+ "unavailable", null, FakeStatusCode.of(StatusCode.Code.UNAVAILABLE), false);
+
+ // Default algorithm retries UNAVAILABLE, but context with empty codes forbids it
+ assertThat(algorithm.shouldRetry(contextWithEmptyCodes, unavailable, null)).isFalse();
+
+ FakeCallContext contextWithCustomCodes =
+ FakeCallContext.createDefault()
+ .withRetryableCodes(ImmutableSet.of(StatusCode.Code.NOT_FOUND));
+
+ NotFoundException notFound =
+ new NotFoundException(
+ "not found", null, FakeStatusCode.of(StatusCode.Code.NOT_FOUND), false);
+
+ // Custom context allows NOT_FOUND
+ assertThat(algorithm.shouldRetry(contextWithCustomCodes, notFound, null)).isTrue();
+ }
+}