diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageErrorTypeMapper.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageErrorTypeMapper.java new file mode 100644 index 000000000000..4d20c1966549 --- /dev/null +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageErrorTypeMapper.java @@ -0,0 +1,336 @@ +/* + * 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 com.google.api.client.http.HttpResponseException; +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.DeadlineExceededException; +import com.google.api.gax.rpc.StatusCode; +import com.google.api.gax.rpc.WatchdogTimeoutException; +import com.google.common.base.Ascii; +import io.grpc.Status; +import io.grpc.StatusException; +import io.grpc.StatusRuntimeException; +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import javax.net.ssl.SSLException; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Centralized package-private utility mapping exceptions and status codes to the standard HLD + * uppercase canonical error strings. + */ +final class StorageErrorTypeMapper { + + static final String STATUS_OK = "OK"; + static final String STATUS_CANCELLED = "CANCELLED"; + static final String STATUS_UNKNOWN = "UNKNOWN"; + static final String STATUS_NOT_FOUND = "NOT_FOUND"; + static final String STATUS_UNAVAILABLE = "UNAVAILABLE"; + + private static final String STATUS_INVALID_ARGUMENT = "INVALID_ARGUMENT"; + private static final String STATUS_ALREADY_EXISTS = "ALREADY_EXISTS"; + private static final String STATUS_PERMISSION_DENIED = "PERMISSION_DENIED"; + private static final String STATUS_RESOURCE_EXHAUSTED = "RESOURCE_EXHAUSTED"; + private static final String STATUS_FAILED_PRECONDITION = "FAILED_PRECONDITION"; + private static final String STATUS_OUT_OF_RANGE = "OUT_OF_RANGE"; + private static final String STATUS_UNIMPLEMENTED = "UNIMPLEMENTED"; + private static final String STATUS_INTERNAL = "INTERNAL"; + private static final String STATUS_UNAUTHENTICATED = "UNAUTHENTICATED"; + + static final String STATUS_TIMEOUT = "TIMEOUT"; + static final String STATUS_DNS_FAILURE = "DNS_FAILURE"; + static final String STATUS_CONNECTION_REFUSED = "CONNECTION_REFUSED"; + static final String STATUS_CONNECTION_RESET = "CONNECTION_RESET"; + static final String STATUS_TLS_FAILURE = "TLS_FAILURE"; + static final String STATUS_CONNECTIVITY = "CONNECTIVITY"; + static final String STATUS_AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"; + static final String STATUS_CHECKSUM_MISMATCH = "CHECKSUM_MISMATCH"; + + static String mapGrpcStatus(@Nullable Status status) { + if (status == null || status.getCode() == Status.Code.UNKNOWN) { + return STATUS_UNKNOWN; + } + if (status.getCode() == Status.Code.DEADLINE_EXCEEDED) { + return STATUS_TIMEOUT; + } + return status.getCode().name(); + } + + private static String mapGaxStatusCode(StatusCode.@Nullable Code code) { + if (code == null || code == StatusCode.Code.UNKNOWN) { + return STATUS_UNKNOWN; + } + if (code == StatusCode.Code.DEADLINE_EXCEEDED) { + return STATUS_TIMEOUT; + } + return code.name(); + } + + static String mapHttpResponseCode(int statusCode) { + if (statusCode >= 200 && statusCode < 300) { + return STATUS_OK; + } + switch (statusCode) { + case 400: + return STATUS_INVALID_ARGUMENT; + case 401: + return STATUS_UNAUTHENTICATED; + case 403: + return STATUS_PERMISSION_DENIED; + case 404: + return STATUS_NOT_FOUND; + case 408: + return STATUS_TIMEOUT; + case 409: + return STATUS_ALREADY_EXISTS; + case 412: + return STATUS_FAILED_PRECONDITION; + case 416: + return STATUS_OUT_OF_RANGE; + case 429: + return STATUS_RESOURCE_EXHAUSTED; + case 499: + return STATUS_CANCELLED; + case 500: + return STATUS_INTERNAL; + case 501: + return STATUS_UNIMPLEMENTED; + case 502: + case 503: + return STATUS_UNAVAILABLE; + case 504: + return STATUS_TIMEOUT; + default: + if (statusCode >= 400 && statusCode < 600) { + return String.valueOf(statusCode); + } + return STATUS_UNKNOWN; + } + } + + static String mapThrowable(@Nullable Throwable t) { + if (t == null) { + return STATUS_OK; + } + + while ((t instanceof ExecutionException || t instanceof CompletionException) + && t.getCause() != null + && t.getCause() != t) { + t = t.getCause(); + } + + if (isChecksumMismatch(t)) { + return STATUS_CHECKSUM_MISMATCH; + } + + if (t instanceof CancellationException) { + return STATUS_CANCELLED; + } + + if (t instanceof StatusRuntimeException) { + String mappedCause = mapSpecificClientCause(t, /* includeAuth= */ true); + if (mappedCause != null) { + return mappedCause; + } + return mapGrpcStatus(((StatusRuntimeException) t).getStatus()); + } + + if (t instanceof StatusException) { + String mappedCause = mapSpecificClientCause(t, /* includeAuth= */ true); + if (mappedCause != null) { + return mappedCause; + } + return mapGrpcStatus(((StatusException) t).getStatus()); + } + + if (t instanceof WatchdogTimeoutException + || t instanceof DeadlineExceededException + || t instanceof SocketTimeoutException + || t instanceof TimeoutException) { + return STATUS_TIMEOUT; + } + + if (t instanceof ApiException) { + String mappedCause = mapSpecificClientCause(t, /* includeAuth= */ true); + if (mappedCause != null) { + return mappedCause; + } + ApiException apiException = (ApiException) t; + StatusCode statusCode = apiException.getStatusCode(); + String mapped = mapGaxStatusCode(statusCode != null ? statusCode.getCode() : null); + if (!STATUS_UNKNOWN.equals(mapped)) { + return mapped; + } + } + + if (t instanceof HttpResponseException) { + String mappedCause = mapSpecificClientCause(t, /* includeAuth= */ true); + if (mappedCause != null) { + return mappedCause; + } + return mapHttpResponseCode(((HttpResponseException) t).getStatusCode()); + } + + if (t instanceof StorageException) { + StorageException se = (StorageException) t; + Throwable cause = se.getCause(); + if (cause != null + && cause != se + && (cause instanceof ApiException + || cause instanceof StatusRuntimeException + || cause instanceof StatusException + || hasSpecificClientCause(cause, /* includeAuth= */ true))) { + String mappedCause = mapKnownCause(se, cause); + if (mappedCause != null) { + return mappedCause; + } + } + if (se.getCode() > 0) { + return mapHttpResponseCode(se.getCode()); + } + } + + if (isAuthException(t)) { + String mappedCause = mapSpecificClientCause(t, /* includeAuth= */ false); + if (mappedCause != null) { + return mappedCause; + } + return STATUS_AUTHENTICATION_ERROR; + } + + if (t instanceof UnknownHostException) { + return STATUS_DNS_FAILURE; + } + + if (t instanceof ConnectException) { + return STATUS_CONNECTION_REFUSED; + } + + if (t instanceof SocketException) { + String message = t.getMessage(); + if (message != null && Ascii.toLowerCase(message).contains("reset")) { + return STATUS_CONNECTION_RESET; + } + return STATUS_CONNECTIVITY; + } + + if (t instanceof SSLException) { + return STATUS_TLS_FAILURE; + } + + if (t instanceof IOException) { + String mappedCause = mapSpecificClientCause(t, /* includeAuth= */ true); + if (mappedCause != null) { + return mappedCause; + } + return STATUS_CONNECTIVITY; + } + + String causeMapping = mapKnownCause(t, t.getCause()); + if (causeMapping != null) { + return causeMapping; + } + + return STATUS_UNKNOWN; + } + + private static @Nullable String mapSpecificClientCause(Throwable t, boolean includeAuth) { + Throwable cause = t.getCause(); + if (cause != null && cause != t && hasSpecificClientCause(cause, includeAuth)) { + return mapKnownCause(t, cause); + } + return null; + } + + private static @Nullable String mapKnownCause( + @Nullable Throwable parent, @Nullable Throwable cause) { + if (cause != null && cause != parent) { + String mappedCause = mapThrowable(cause); + if (!STATUS_UNKNOWN.equals(mappedCause)) { + return mappedCause; + } + } + return null; + } + + private static boolean hasSpecificClientCause(@Nullable Throwable t, boolean includeAuth) { + Throwable current = t; + while (current != null) { + if (current instanceof WatchdogTimeoutException + || current instanceof DeadlineExceededException + || current instanceof SocketTimeoutException + || current instanceof TimeoutException + || current instanceof UnknownHostException + || current instanceof ConnectException + || current instanceof SocketException + || current instanceof SSLException + || current instanceof CancellationException + || isChecksumMismatch(current) + || (includeAuth && isAuthException(current))) { + return true; + } + Throwable next = current.getCause(); + if (next == current) { + break; + } + current = next; + } + return false; + } + + private static boolean isChecksumMismatch(Throwable t) { + if (t instanceof Hasher.ChecksumMismatchException + || t instanceof Hasher.UncheckedChecksumMismatchException) { + return true; + } + String message = t.getMessage(); + if (message != null) { + String lower = Ascii.toLowerCase(message); + if (lower.contains("mismatch checksum") || lower.contains("checksum mismatch")) { + return true; + } + } + return false; + } + + private static boolean isAuthException(Throwable t) { + for (Class cls = t.getClass(); + cls != null && cls != Throwable.class; + cls = cls.getSuperclass()) { + String className = cls.getName(); + String simpleName = cls.getSimpleName(); + if (className.startsWith("com.google.auth.") + || className.startsWith("com.google.api.client.auth.") + || className.contains("GoogleAuthException") + || simpleName.equals("OAuth2Exception") + || simpleName.equals("AuthenticationException")) { + return true; + } + } + return false; + } + + private StorageErrorTypeMapper() {} +} diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageErrorTypeMapperTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageErrorTypeMapperTest.java new file mode 100644 index 000000000000..5a5f6a70b813 --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageErrorTypeMapperTest.java @@ -0,0 +1,570 @@ +/* + * 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 com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; +import com.google.api.gax.grpc.GrpcStatusCode; +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.DeadlineExceededException; +import com.google.api.gax.rpc.StatusCode; +import com.google.api.gax.rpc.WatchdogTimeoutException; +import com.google.auth.ServiceAccountSigner.SigningException; +import io.grpc.Status; +import io.grpc.StatusException; +import io.grpc.StatusRuntimeException; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.net.ConnectException; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLHandshakeException; +import org.junit.Test; + +public final class StorageErrorTypeMapperTest { + + private static final Crc32cValue EXPECTED_CRC32C = Crc32cValue.of(12345, 100); + private static final Crc32cValue.Crc32cLengthKnown ACTUAL_CRC32C = Crc32cValue.of(54321, 100); + + @Test + public void mapGrpcStatus_allStatusesMappedCorrectly() { + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.OK)).isEqualTo("OK"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.CANCELLED)).isEqualTo("CANCELLED"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.UNKNOWN)).isEqualTo("UNKNOWN"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.INVALID_ARGUMENT)) + .isEqualTo("INVALID_ARGUMENT"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.DEADLINE_EXCEEDED)).isEqualTo("TIMEOUT"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.NOT_FOUND)).isEqualTo("NOT_FOUND"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.ALREADY_EXISTS)) + .isEqualTo("ALREADY_EXISTS"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.PERMISSION_DENIED)) + .isEqualTo("PERMISSION_DENIED"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.RESOURCE_EXHAUSTED)) + .isEqualTo("RESOURCE_EXHAUSTED"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.FAILED_PRECONDITION)) + .isEqualTo("FAILED_PRECONDITION"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.ABORTED)).isEqualTo("ABORTED"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.OUT_OF_RANGE)).isEqualTo("OUT_OF_RANGE"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.UNIMPLEMENTED)) + .isEqualTo("UNIMPLEMENTED"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.INTERNAL)).isEqualTo("INTERNAL"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.UNAVAILABLE)).isEqualTo("UNAVAILABLE"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.DATA_LOSS)).isEqualTo("DATA_LOSS"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(Status.UNAUTHENTICATED)) + .isEqualTo("UNAUTHENTICATED"); + assertThat(StorageErrorTypeMapper.mapGrpcStatus(null)).isEqualTo("UNKNOWN"); + } + + @Test + public void mapHttpResponseCode_standardCodesMappedCorrectly() { + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(200)).isEqualTo("OK"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(201)).isEqualTo("OK"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(204)).isEqualTo("OK"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(299)).isEqualTo("OK"); + + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(400)).isEqualTo("INVALID_ARGUMENT"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(401)).isEqualTo("UNAUTHENTICATED"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(403)).isEqualTo("PERMISSION_DENIED"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(404)).isEqualTo("NOT_FOUND"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(408)).isEqualTo("TIMEOUT"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(409)).isEqualTo("ALREADY_EXISTS"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(412)).isEqualTo("FAILED_PRECONDITION"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(416)).isEqualTo("OUT_OF_RANGE"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(429)).isEqualTo("RESOURCE_EXHAUSTED"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(499)).isEqualTo("CANCELLED"); + + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(500)).isEqualTo("INTERNAL"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(501)).isEqualTo("UNIMPLEMENTED"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(502)).isEqualTo("UNAVAILABLE"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(503)).isEqualTo("UNAVAILABLE"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(504)).isEqualTo("TIMEOUT"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(505)).isEqualTo("505"); + + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(418)).isEqualTo("418"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(0)).isEqualTo("UNKNOWN"); + assertThat(StorageErrorTypeMapper.mapHttpResponseCode(-1)).isEqualTo("UNKNOWN"); + } + + @Test + public void mapThrowable_nullReturnsOk() { + assertThat(StorageErrorTypeMapper.mapThrowable(null)).isEqualTo("OK"); + } + + @Test + public void mapThrowable_grpcExceptions() { + StatusRuntimeException sre = new StatusRuntimeException(Status.DEADLINE_EXCEEDED); + assertThat(StorageErrorTypeMapper.mapThrowable(sre)).isEqualTo("TIMEOUT"); + + StatusException se = new StatusException(Status.PERMISSION_DENIED); + assertThat(StorageErrorTypeMapper.mapThrowable(se)).isEqualTo("PERMISSION_DENIED"); + + ApiException apiEx = + new ApiException( + new StatusRuntimeException(Status.NOT_FOUND), + GrpcStatusCode.of(Status.Code.NOT_FOUND), + /* retryable= */ false); + assertThat(StorageErrorTypeMapper.mapThrowable(apiEx)).isEqualTo("NOT_FOUND"); + } + + @Test + public void mapThrowable_apiExceptionWithNullStatusCode() { + ApiException apiExNullStatusCode = + new ApiException( + "Null status code", + /* cause= */ null, + GrpcStatusCode.of(Status.Code.UNKNOWN), + /* retryable= */ false) { + @Override + public StatusCode getStatusCode() { + return null; + } + }; + assertThat(StorageErrorTypeMapper.mapThrowable(apiExNullStatusCode)).isEqualTo("UNKNOWN"); + + ApiException apiExNullStatusCodeWithTimeoutCause = + new ApiException( + "Null status code with timeout", + new SocketTimeoutException("Read timed out"), + GrpcStatusCode.of(Status.Code.UNKNOWN), + /* retryable= */ false) { + @Override + public StatusCode getStatusCode() { + return null; + } + }; + assertThat(StorageErrorTypeMapper.mapThrowable(apiExNullStatusCodeWithTimeoutCause)) + .isEqualTo("TIMEOUT"); + } + + @Test + public void mapThrowable_gaxTimeoutExceptions() throws Exception { + DeadlineExceededException dee = + new DeadlineExceededException( + new StatusRuntimeException(Status.DEADLINE_EXCEEDED), + GrpcStatusCode.of(Status.Code.DEADLINE_EXCEEDED), + /* retryable= */ false); + assertThat(StorageErrorTypeMapper.mapThrowable(dee)).isEqualTo("TIMEOUT"); + + WatchdogTimeoutException wte = createWatchdogTimeoutException(); + assertThat(StorageErrorTypeMapper.mapThrowable(wte)).isEqualTo("TIMEOUT"); + } + + @Test + public void mapThrowable_httpExceptions() { + HttpResponseException hre = + new HttpResponseException.Builder(503, "Service Unavailable", new HttpHeaders()).build(); + assertThat(StorageErrorTypeMapper.mapThrowable(hre)).isEqualTo("UNAVAILABLE"); + + GoogleJsonError error = new GoogleJsonError(); + error.setCode(404); + error.setMessage("Not Found"); + GoogleJsonResponseException gjre = + new GoogleJsonResponseException( + new HttpResponseException.Builder(404, "Not Found", new HttpHeaders()), error); + assertThat(StorageErrorTypeMapper.mapThrowable(gjre)).isEqualTo("NOT_FOUND"); + + StorageException se404 = new StorageException(404, "Not found"); + assertThat(StorageErrorTypeMapper.mapThrowable(se404)).isEqualTo("NOT_FOUND"); + + StorageException se401 = new StorageException(401, "Unauthorized"); + assertThat(StorageErrorTypeMapper.mapThrowable(se401)).isEqualTo("UNAUTHENTICATED"); + } + + @Test + public void mapThrowable_httpResponseExceptionWithSpecificClientCause() { + HttpResponseException hreWithTimeout = + new HttpResponseException.Builder(500, "Server Error", new HttpHeaders()).build(); + hreWithTimeout.initCause(new SocketTimeoutException("Read timed out")); + assertThat(StorageErrorTypeMapper.mapThrowable(hreWithTimeout)).isEqualTo("TIMEOUT"); + + HttpResponseException hre400WithTimeout = + new HttpResponseException.Builder(400, "Bad Request", new HttpHeaders()).build(); + hre400WithTimeout.initCause(new SocketTimeoutException("Read timed out")); + GoogleAuthException authWrappingHttpWrappingTimeout = + new GoogleAuthException("Token refresh failed", hre400WithTimeout); + assertThat(StorageErrorTypeMapper.mapThrowable(authWrappingHttpWrappingTimeout)) + .isEqualTo("TIMEOUT"); + } + + @Test + public void mapThrowable_networkAndTlsExceptions() { + assertThat(StorageErrorTypeMapper.mapThrowable(new SocketTimeoutException("Read timed out"))) + .isEqualTo("TIMEOUT"); + assertThat(StorageErrorTypeMapper.mapThrowable(new TimeoutException("Operation timed out"))) + .isEqualTo("TIMEOUT"); + assertThat( + StorageErrorTypeMapper.mapThrowable(new UnknownHostException("storage.googleapis.com"))) + .isEqualTo("DNS_FAILURE"); + assertThat(StorageErrorTypeMapper.mapThrowable(new ConnectException("Connection refused"))) + .isEqualTo("CONNECTION_REFUSED"); + assertThat(StorageErrorTypeMapper.mapThrowable(new SocketException("Connection reset"))) + .isEqualTo("CONNECTION_RESET"); + assertThat(StorageErrorTypeMapper.mapThrowable(new SocketException("Broken pipe"))) + .isEqualTo("CONNECTIVITY"); + assertThat(StorageErrorTypeMapper.mapThrowable(new SSLHandshakeException("Handshake failed"))) + .isEqualTo("TLS_FAILURE"); + assertThat(StorageErrorTypeMapper.mapThrowable(new SSLException("SSL error"))) + .isEqualTo("TLS_FAILURE"); + assertThat(StorageErrorTypeMapper.mapThrowable(new IOException("Broken pipe"))) + .isEqualTo("CONNECTIVITY"); + assertThat(StorageErrorTypeMapper.mapThrowable(new CancellationException("Cancelled"))) + .isEqualTo("CANCELLED"); + } + + @Test + public void mapThrowable_authExceptions() { + Throwable signingEx = new SigningException("Signing error", new RuntimeException()); + assertThat(StorageErrorTypeMapper.mapThrowable(signingEx)).isEqualTo("AUTHENTICATION_ERROR"); + + assertThat(StorageErrorTypeMapper.mapThrowable(new GoogleAuthException("Auth failed"))) + .isEqualTo("AUTHENTICATION_ERROR"); + assertThat(StorageErrorTypeMapper.mapThrowable(new OAuth2Exception("Token invalid"))) + .isEqualTo("AUTHENTICATION_ERROR"); + assertThat(StorageErrorTypeMapper.mapThrowable(new AuthenticationException("Auth failed"))) + .isEqualTo("AUTHENTICATION_ERROR"); + assertThat(StorageErrorTypeMapper.mapThrowable(new AuthorNotFoundException("Not found"))) + .isEqualTo("UNKNOWN"); + } + + @Test + public void mapThrowable_anonymousAndSubclassAuthExceptions() { + Throwable anonymousOAuth2 = new OAuth2Exception("Anonymous token invalid") {}; + assertThat(StorageErrorTypeMapper.mapThrowable(anonymousOAuth2)) + .isEqualTo("AUTHENTICATION_ERROR"); + + Throwable anonymousAuth = new AuthenticationException("Anonymous auth failed") {}; + assertThat(StorageErrorTypeMapper.mapThrowable(anonymousAuth)) + .isEqualTo("AUTHENTICATION_ERROR"); + + Throwable customSubclass = new CustomTokenException("Subclass of GoogleAuthException"); + assertThat(StorageErrorTypeMapper.mapThrowable(customSubclass)) + .isEqualTo("AUTHENTICATION_ERROR"); + } + + @Test + public void mapThrowable_checksumMismatchExceptions() { + Hasher.ChecksumMismatchException cme = + new Hasher.ChecksumMismatchException(EXPECTED_CRC32C, ACTUAL_CRC32C); + assertThat(StorageErrorTypeMapper.mapThrowable(cme)).isEqualTo("CHECKSUM_MISMATCH"); + + Hasher.UncheckedChecksumMismatchException ucme = + new Hasher.UncheckedChecksumMismatchException(EXPECTED_CRC32C, ACTUAL_CRC32C); + assertThat(StorageErrorTypeMapper.mapThrowable(ucme)).isEqualTo("CHECKSUM_MISMATCH"); + + IOException messageMismatch = new IOException("Checksum mismatch for downloaded object"); + assertThat(StorageErrorTypeMapper.mapThrowable(messageMismatch)).isEqualTo("CHECKSUM_MISMATCH"); + } + + @Test + public void mapThrowable_unwrapsWrapperExceptions() { + SocketTimeoutException root = new SocketTimeoutException("timeout"); + ExecutionException ee = new ExecutionException(root); + assertThat(StorageErrorTypeMapper.mapThrowable(ee)).isEqualTo("TIMEOUT"); + + CompletionException ce = new CompletionException(new UnknownHostException("host")); + assertThat(StorageErrorTypeMapper.mapThrowable(ce)).isEqualTo("DNS_FAILURE"); + + StorageException seWithCause = new StorageException(0, "Wrapped IO", root); + assertThat(StorageErrorTypeMapper.mapThrowable(seWithCause)).isEqualTo("TIMEOUT"); + } + + @Test + public void mapThrowable_storageExceptionWithSpecificClientCauseUnwrapped() throws Exception { + WatchdogTimeoutException wte = createWatchdogTimeoutException(); + StorageException se500WithTimeout = new StorageException(500, "Internal error", wte); + assertThat(StorageErrorTypeMapper.mapThrowable(se500WithTimeout)).isEqualTo("TIMEOUT"); + + Hasher.ChecksumMismatchException cme = + new Hasher.ChecksumMismatchException(EXPECTED_CRC32C, ACTUAL_CRC32C); + StorageException se400WithChecksum = new StorageException(400, "Bad Request", cme); + assertThat(StorageErrorTypeMapper.mapThrowable(se400WithChecksum)) + .isEqualTo("CHECKSUM_MISMATCH"); + + SSLHandshakeException tlsEx = new SSLHandshakeException("Handshake failed"); + StorageException se500WithTls = new StorageException(500, "Internal error", tlsEx); + assertThat(StorageErrorTypeMapper.mapThrowable(se500WithTls)).isEqualTo("TLS_FAILURE"); + + SocketTimeoutException ste = new SocketTimeoutException("Read timed out"); + StorageException se504WithSocketTimeout = new StorageException(504, "Gateway timeout", ste); + assertThat(StorageErrorTypeMapper.mapThrowable(se504WithSocketTimeout)).isEqualTo("TIMEOUT"); + } + + @Test + public void mapThrowable_storageExceptionWrappingApiExceptions() { + ApiException apiExAborted = + new ApiException( + new StatusRuntimeException(Status.ABORTED), + GrpcStatusCode.of(Status.Code.ABORTED), + /* retryable= */ false); + StorageException seAborted = new StorageException(409, "Aborted", apiExAborted); + assertThat(StorageErrorTypeMapper.mapThrowable(seAborted)).isEqualTo("ABORTED"); + + ApiException apiExPrecondition = + new ApiException( + new StatusRuntimeException(Status.FAILED_PRECONDITION), + GrpcStatusCode.of(Status.Code.FAILED_PRECONDITION), + /* retryable= */ false); + StorageException sePrecondition = + new StorageException(400, "Failed Precondition", apiExPrecondition); + assertThat(StorageErrorTypeMapper.mapThrowable(sePrecondition)) + .isEqualTo("FAILED_PRECONDITION"); + + ApiException apiExOutOfRange = + new ApiException( + new StatusRuntimeException(Status.OUT_OF_RANGE), + GrpcStatusCode.of(Status.Code.OUT_OF_RANGE), + /* retryable= */ false); + StorageException seOutOfRange = new StorageException(400, "Out of Range", apiExOutOfRange); + assertThat(StorageErrorTypeMapper.mapThrowable(seOutOfRange)).isEqualTo("OUT_OF_RANGE"); + + ApiException apiExUnauthenticated = + new ApiException( + new StatusRuntimeException(Status.UNAUTHENTICATED), + GrpcStatusCode.of(Status.Code.UNAUTHENTICATED), + /* retryable= */ false); + StorageException seUnauthenticated = + new StorageException(401, "Unauthenticated", apiExUnauthenticated); + assertThat(StorageErrorTypeMapper.mapThrowable(seUnauthenticated)).isEqualTo("UNAUTHENTICATED"); + } + + @Test + public void mapThrowable_storageExceptionCoalescedFromApiExceptions() { + ApiException apiExAborted = + new ApiException( + new StatusRuntimeException(Status.ABORTED), + GrpcStatusCode.of(Status.Code.ABORTED), + /* retryable= */ false); + ApiException apiExPrecondition = + new ApiException( + new StatusRuntimeException(Status.FAILED_PRECONDITION), + GrpcStatusCode.of(Status.Code.FAILED_PRECONDITION), + /* retryable= */ false); + ApiException apiExOutOfRange = + new ApiException( + new StatusRuntimeException(Status.OUT_OF_RANGE), + GrpcStatusCode.of(Status.Code.OUT_OF_RANGE), + /* retryable= */ false); + + assertThat(StorageErrorTypeMapper.mapThrowable(StorageException.coalesce(apiExAborted))) + .isEqualTo("ABORTED"); + assertThat(StorageErrorTypeMapper.mapThrowable(StorageException.coalesce(apiExPrecondition))) + .isEqualTo("FAILED_PRECONDITION"); + assertThat(StorageErrorTypeMapper.mapThrowable(StorageException.coalesce(apiExOutOfRange))) + .isEqualTo("OUT_OF_RANGE"); + } + + @Test + public void mapThrowable_storageExceptionWrappingDirectGrpcStatusExceptions() { + StorageException seFromStatusRuntime = + new StorageException( + 500, "gRPC Status Runtime", new StatusRuntimeException(Status.ABORTED)); + assertThat(StorageErrorTypeMapper.mapThrowable(seFromStatusRuntime)).isEqualTo("ABORTED"); + + StorageException seFromStatus = + new StorageException(500, "gRPC Status", new StatusException(Status.FAILED_PRECONDITION)); + assertThat(StorageErrorTypeMapper.mapThrowable(seFromStatus)).isEqualTo("FAILED_PRECONDITION"); + } + + @Test + public void mapThrowable_storageExceptionWrappingSocketResetException() { + StorageException seFromSocketReset = + new StorageException(0, "Socket Reset", new SocketException("Connection reset")); + assertThat(StorageErrorTypeMapper.mapThrowable(seFromSocketReset)) + .isEqualTo("CONNECTION_RESET"); + } + + @Test + public void mapThrowable_authExceptionWrappingDirectNetworkAndTimeoutCauses() { + GoogleAuthException authTimeout = + new GoogleAuthException("Auth timeout", new SocketTimeoutException("Read timed out")); + assertThat(StorageErrorTypeMapper.mapThrowable(authTimeout)).isEqualTo("TIMEOUT"); + + GoogleAuthException authDns = + new GoogleAuthException( + "Auth DNS failure", new UnknownHostException("oauth2.googleapis.com")); + assertThat(StorageErrorTypeMapper.mapThrowable(authDns)).isEqualTo("DNS_FAILURE"); + + GoogleAuthException authRefused = + new GoogleAuthException("Auth connect refused", new ConnectException("Connection refused")); + assertThat(StorageErrorTypeMapper.mapThrowable(authRefused)).isEqualTo("CONNECTION_REFUSED"); + + GoogleAuthException authTls = + new GoogleAuthException( + "Auth TLS failed", new SSLHandshakeException("TLS handshake failed")); + assertThat(StorageErrorTypeMapper.mapThrowable(authTls)).isEqualTo("TLS_FAILURE"); + } + + @Test + public void mapThrowable_authExceptionWrappingNestedTimeoutCause() { + GoogleAuthException nestedAuthTimeout = + new GoogleAuthException( + "Nested timeout", new IOException("IO failed", new SocketTimeoutException("timeout"))); + assertThat(StorageErrorTypeMapper.mapThrowable(nestedAuthTimeout)).isEqualTo("TIMEOUT"); + + GoogleAuthException authTimeout = + new GoogleAuthException("Auth timeout", new SocketTimeoutException("Read timed out")); + StorageException se401WithTimeout = new StorageException(401, "Unauthorized", authTimeout); + assertThat(StorageErrorTypeMapper.mapThrowable(se401WithTimeout)).isEqualTo("TIMEOUT"); + } + + @Test + public void mapThrowable_authExceptionWithoutSpecificNetworkCauseMapsToAuthError() { + GoogleAuthException simpleAuth = new GoogleAuthException("Invalid credentials"); + assertThat(StorageErrorTypeMapper.mapThrowable(simpleAuth)).isEqualTo("AUTHENTICATION_ERROR"); + + GoogleAuthException authWithGenericIo = + new GoogleAuthException("Grant error", new IOException("Invalid grant")); + assertThat(StorageErrorTypeMapper.mapThrowable(authWithGenericIo)) + .isEqualTo("AUTHENTICATION_ERROR"); + } + + @Test + public void mapThrowable_statusRuntimeExceptionWithNetworkCauses() { + StatusRuntimeException sreDns = + new StatusRuntimeException( + Status.UNAVAILABLE.withCause(new UnknownHostException("storage.googleapis.com"))); + assertThat(StorageErrorTypeMapper.mapThrowable(sreDns)).isEqualTo("DNS_FAILURE"); + + StatusRuntimeException sreTls = + new StatusRuntimeException( + Status.UNAVAILABLE.withCause(new SSLHandshakeException("TLS failed"))); + assertThat(StorageErrorTypeMapper.mapThrowable(sreTls)).isEqualTo("TLS_FAILURE"); + + StatusRuntimeException sreRefused = + new StatusRuntimeException( + Status.UNAVAILABLE.withCause(new ConnectException("Connection refused"))); + assertThat(StorageErrorTypeMapper.mapThrowable(sreRefused)).isEqualTo("CONNECTION_REFUSED"); + + StatusRuntimeException sreReset = + new StatusRuntimeException( + Status.UNAVAILABLE.withCause(new SocketException("Connection reset"))); + assertThat(StorageErrorTypeMapper.mapThrowable(sreReset)).isEqualTo("CONNECTION_RESET"); + + StatusRuntimeException srePipe = + new StatusRuntimeException( + Status.UNAVAILABLE.withCause(new SocketException("Broken pipe"))); + assertThat(StorageErrorTypeMapper.mapThrowable(srePipe)).isEqualTo("CONNECTIVITY"); + } + + @Test + public void mapThrowable_statusExceptionWithNetworkCauses() { + StatusException seDns = + new StatusException( + Status.UNAVAILABLE.withCause(new UnknownHostException("storage.googleapis.com"))); + assertThat(StorageErrorTypeMapper.mapThrowable(seDns)).isEqualTo("DNS_FAILURE"); + + StatusException seTls = + new StatusException(Status.UNAVAILABLE.withCause(new SSLHandshakeException("TLS failed"))); + assertThat(StorageErrorTypeMapper.mapThrowable(seTls)).isEqualTo("TLS_FAILURE"); + } + + @Test + public void mapThrowable_apiExceptionWithNetworkCauses() { + StatusRuntimeException sreDns = + new StatusRuntimeException( + Status.UNAVAILABLE.withCause(new UnknownHostException("storage.googleapis.com"))); + ApiException apiExDns = + new ApiException( + sreDns, GrpcStatusCode.of(Status.Code.UNAVAILABLE), /* retryable= */ false); + assertThat(StorageErrorTypeMapper.mapThrowable(apiExDns)).isEqualTo("DNS_FAILURE"); + + ApiException apiExRefused = + new ApiException( + new ConnectException("Refused"), + GrpcStatusCode.of(Status.Code.UNAVAILABLE), + /* retryable= */ false); + assertThat(StorageErrorTypeMapper.mapThrowable(apiExRefused)).isEqualTo("CONNECTION_REFUSED"); + } + + @Test + public void mapThrowable_selfReferencingExceptionTerminates() { + class SelfCausedExecutionException extends ExecutionException { + @Override + public synchronized Throwable getCause() { + return this; + } + } + assertThat(StorageErrorTypeMapper.mapThrowable(new SelfCausedExecutionException())) + .isEqualTo("UNKNOWN"); + + class SelfCausedCompletionException extends CompletionException { + @Override + public synchronized Throwable getCause() { + return this; + } + } + assertThat(StorageErrorTypeMapper.mapThrowable(new SelfCausedCompletionException())) + .isEqualTo("UNKNOWN"); + } + + @Test + public void mapThrowable_unknownFallback() { + assertThat(StorageErrorTypeMapper.mapThrowable(new IllegalStateException("unexpected state"))) + .isEqualTo("UNKNOWN"); + } + + private static WatchdogTimeoutException createWatchdogTimeoutException() throws Exception { + Constructor ctor = + WatchdogTimeoutException.class.getDeclaredConstructor(String.class, boolean.class); + ctor.setAccessible(true); + return ctor.newInstance("Watchdog timeout", /* retryable= */ true); + } + + private static class GoogleAuthException extends RuntimeException { + GoogleAuthException(String msg) { + super(msg); + } + + GoogleAuthException(String msg, Throwable cause) { + super(msg, cause); + } + } + + private static final class CustomTokenException extends GoogleAuthException { + CustomTokenException(String msg) { + super(msg); + } + } + + private static class OAuth2Exception extends RuntimeException { + OAuth2Exception(String msg) { + super(msg); + } + } + + private static class AuthenticationException extends RuntimeException { + AuthenticationException(String msg) { + super(msg); + } + } + + private static final class AuthorNotFoundException extends RuntimeException { + AuthorNotFoundException(String msg) { + super(msg); + } + } +}