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..0ea09f3e628b --- /dev/null +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageErrorTypeMapper.java @@ -0,0 +1,379 @@ +/* + * 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 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.Locale; +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.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_INVALID_ARGUMENT = "INVALID_ARGUMENT"; + static final String STATUS_NOT_FOUND = "NOT_FOUND"; + static final String STATUS_ALREADY_EXISTS = "ALREADY_EXISTS"; + static final String STATUS_PERMISSION_DENIED = "PERMISSION_DENIED"; + static final String STATUS_RESOURCE_EXHAUSTED = "RESOURCE_EXHAUSTED"; + static final String STATUS_FAILED_PRECONDITION = "FAILED_PRECONDITION"; + static final String STATUS_ABORTED = "ABORTED"; + static final String STATUS_OUT_OF_RANGE = "OUT_OF_RANGE"; + static final String STATUS_UNIMPLEMENTED = "UNIMPLEMENTED"; + static final String STATUS_INTERNAL = "INTERNAL"; + static final String STATUS_UNAVAILABLE = "UNAVAILABLE"; + static final String STATUS_DATA_LOSS = "DATA_LOSS"; + 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"; + + private StorageErrorTypeMapper() {} + + static String mapGrpcStatus(@Nullable Status status) { + if (status == null) { + return STATUS_UNKNOWN; + } + switch (status.getCode()) { + case OK: + return STATUS_OK; + case CANCELLED: + return STATUS_CANCELLED; + case DEADLINE_EXCEEDED: + return STATUS_TIMEOUT; + case INVALID_ARGUMENT: + return STATUS_INVALID_ARGUMENT; + case NOT_FOUND: + return STATUS_NOT_FOUND; + case ALREADY_EXISTS: + return STATUS_ALREADY_EXISTS; + case PERMISSION_DENIED: + return STATUS_PERMISSION_DENIED; + case RESOURCE_EXHAUSTED: + return STATUS_RESOURCE_EXHAUSTED; + case FAILED_PRECONDITION: + return STATUS_FAILED_PRECONDITION; + case ABORTED: + return STATUS_ABORTED; + case OUT_OF_RANGE: + return STATUS_OUT_OF_RANGE; + case UNIMPLEMENTED: + return STATUS_UNIMPLEMENTED; + case INTERNAL: + return STATUS_INTERNAL; + case UNAVAILABLE: + return STATUS_UNAVAILABLE; + case DATA_LOSS: + return STATUS_DATA_LOSS; + case UNAUTHENTICATED: + return STATUS_UNAUTHENTICATED; + case UNKNOWN: + default: + return STATUS_UNKNOWN; + } + } + + 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) { + if (t.getCause() != null && t.getCause() != t && hasSpecificClientCause(t.getCause(), true)) { + return mapThrowable(t.getCause()); + } + return mapGrpcStatus(((StatusRuntimeException) t).getStatus()); + } + + if (t instanceof StatusException) { + if (t.getCause() != null && t.getCause() != t && hasSpecificClientCause(t.getCause(), true)) { + return mapThrowable(t.getCause()); + } + return mapGrpcStatus(((StatusException) t).getStatus()); + } + + if (t instanceof WatchdogTimeoutException || t instanceof DeadlineExceededException) { + return STATUS_TIMEOUT; + } + + if (t instanceof ApiException) { + if (t.getCause() != null && t.getCause() != t && hasSpecificClientCause(t.getCause(), true)) { + return mapThrowable(t.getCause()); + } + ApiException apiException = (ApiException) t; + StatusCode.Code code = apiException.getStatusCode().getCode(); + String mapped = mapGaxStatusCode(code); + if (!STATUS_UNKNOWN.equals(mapped)) { + return mapped; + } + } + + if (t instanceof HttpResponseException) { + 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, true))) { + String mappedCause = mapThrowable(cause); + if (!STATUS_UNKNOWN.equals(mappedCause)) { + return mappedCause; + } + } + if (se.getCode() > 0) { + return mapHttpResponseCode(se.getCode()); + } + } + + if (isAuthException(t)) { + if (t.getCause() != null + && t.getCause() != t + && hasSpecificClientCause(t.getCause(), false)) { + String mappedCause = mapThrowable(t.getCause()); + if (!STATUS_UNKNOWN.equals(mappedCause)) { + return mappedCause; + } + } + return STATUS_AUTHENTICATION_ERROR; + } + + if (t instanceof SocketTimeoutException || t instanceof TimeoutException) { + return STATUS_TIMEOUT; + } + + 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 && message.toLowerCase(Locale.US).contains("reset")) { + return STATUS_CONNECTION_RESET; + } + return STATUS_CONNECTIVITY; + } + + if (t instanceof SSLHandshakeException || t instanceof SSLException) { + return STATUS_TLS_FAILURE; + } + + if (t instanceof IOException) { + if (t.getCause() != null && t.getCause() != t && hasSpecificClientCause(t.getCause(), true)) { + String mappedCause = mapThrowable(t.getCause()); + if (!STATUS_UNKNOWN.equals(mappedCause)) { + return mappedCause; + } + } + return STATUS_CONNECTIVITY; + } + + if (t.getCause() != null && t.getCause() != t) { + String causeMapping = mapThrowable(t.getCause()); + if (!STATUS_UNKNOWN.equals(causeMapping)) { + return causeMapping; + } + } + + return STATUS_UNKNOWN; + } + + 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 SSLHandshakeException + || 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 = message.toLowerCase(Locale.US); + if (lower.contains("mismatch checksum") || lower.contains("checksum mismatch")) { + return true; + } + } + return false; + } + + private static boolean isAuthException(Throwable t) { + String className = t.getClass().getName(); + String simpleName = t.getClass().getSimpleName(); + return className.startsWith("com.google.auth.") + || className.startsWith("com.google.api.client.auth.") + || simpleName.equals("GoogleAuthException") + || simpleName.equals("OAuth2Exception") + || simpleName.equals("AuthenticationException") + || className.contains("GoogleAuthException"); + } + + private static String mapGaxStatusCode(StatusCode.Code code) { + switch (code) { + case OK: + return STATUS_OK; + case CANCELLED: + return STATUS_CANCELLED; + case DEADLINE_EXCEEDED: + return STATUS_TIMEOUT; + case INVALID_ARGUMENT: + return STATUS_INVALID_ARGUMENT; + case NOT_FOUND: + return STATUS_NOT_FOUND; + case ALREADY_EXISTS: + return STATUS_ALREADY_EXISTS; + case PERMISSION_DENIED: + return STATUS_PERMISSION_DENIED; + case RESOURCE_EXHAUSTED: + return STATUS_RESOURCE_EXHAUSTED; + case FAILED_PRECONDITION: + return STATUS_FAILED_PRECONDITION; + case ABORTED: + return STATUS_ABORTED; + case OUT_OF_RANGE: + return STATUS_OUT_OF_RANGE; + case UNIMPLEMENTED: + return STATUS_UNIMPLEMENTED; + case INTERNAL: + return STATUS_INTERNAL; + case UNAVAILABLE: + return STATUS_UNAVAILABLE; + case DATA_LOSS: + return STATUS_DATA_LOSS; + case UNAUTHENTICATED: + return STATUS_UNAUTHENTICATED; + case UNKNOWN: + default: + return STATUS_UNKNOWN; + } + } +} 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..3ef2d5c25c65 --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageErrorTypeMapperTest.java @@ -0,0 +1,474 @@ +/* + * 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.WatchdogTimeoutException; +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 { + + @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), + false); + assertThat(StorageErrorTypeMapper.mapThrowable(apiEx)).isEqualTo("NOT_FOUND"); + } + + @Test + public void mapThrowable_gaxTimeoutExceptions() throws Exception { + DeadlineExceededException dee = + new DeadlineExceededException( + new StatusRuntimeException(Status.DEADLINE_EXCEEDED), + GrpcStatusCode.of(Status.Code.DEADLINE_EXCEEDED), + 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_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 com.google.auth.ServiceAccountSigner.SigningException( + "Signing error", new RuntimeException()); + assertThat(StorageErrorTypeMapper.mapThrowable(signingEx)).isEqualTo("AUTHENTICATION_ERROR"); + + class GoogleAuthException extends RuntimeException { + GoogleAuthException(String msg) { + super(msg); + } + } + assertThat(StorageErrorTypeMapper.mapThrowable(new GoogleAuthException("Auth failed"))) + .isEqualTo("AUTHENTICATION_ERROR"); + + class OAuth2Exception extends RuntimeException { + OAuth2Exception(String msg) { + super(msg); + } + } + assertThat(StorageErrorTypeMapper.mapThrowable(new OAuth2Exception("Token invalid"))) + .isEqualTo("AUTHENTICATION_ERROR"); + + class AuthenticationException extends RuntimeException { + AuthenticationException(String msg) { + super(msg); + } + } + assertThat(StorageErrorTypeMapper.mapThrowable(new AuthenticationException("Auth failed"))) + .isEqualTo("AUTHENTICATION_ERROR"); + + class AuthorNotFoundException extends RuntimeException { + AuthorNotFoundException(String msg) { + super(msg); + } + } + assertThat(StorageErrorTypeMapper.mapThrowable(new AuthorNotFoundException("Not found"))) + .isEqualTo("UNKNOWN"); + } + + @Test + public void mapThrowable_checksumMismatchExceptions() { + Crc32cValue expected = Crc32cValue.of(12345, 100); + Crc32cValue.Crc32cLengthKnown actual = Crc32cValue.of(54321, 100); + Hasher.ChecksumMismatchException cme = new Hasher.ChecksumMismatchException(expected, actual); + assertThat(StorageErrorTypeMapper.mapThrowable(cme)).isEqualTo("CHECKSUM_MISMATCH"); + + Hasher.UncheckedChecksumMismatchException ucme = + new Hasher.UncheckedChecksumMismatchException(expected, actual); + 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"); + + Crc32cValue expected = Crc32cValue.of(12345, 100); + Crc32cValue.Crc32cLengthKnown actual = Crc32cValue.of(54321, 100); + Hasher.ChecksumMismatchException cme = new Hasher.ChecksumMismatchException(expected, actual); + 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_storageExceptionWrappingGrpcExceptions() { + ApiException apiExAborted = + new ApiException( + new StatusRuntimeException(Status.ABORTED), + GrpcStatusCode.of(Status.Code.ABORTED), + 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), + 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), + 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), + false); + StorageException seUnauthenticated = + new StorageException(401, "Unauthenticated", apiExUnauthenticated); + assertThat(StorageErrorTypeMapper.mapThrowable(seUnauthenticated)).isEqualTo("UNAUTHENTICATED"); + + // Coalesced via StorageException.coalesce + 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"); + + // StatusRuntimeException directly wrapped + StorageException seFromStatusRuntime = + new StorageException( + 500, "gRPC Status Runtime", new StatusRuntimeException(Status.ABORTED)); + assertThat(StorageErrorTypeMapper.mapThrowable(seFromStatusRuntime)).isEqualTo("ABORTED"); + + // StatusException directly wrapped + StorageException seFromStatus = + new StorageException(500, "gRPC Status", new StatusException(Status.FAILED_PRECONDITION)); + assertThat(StorageErrorTypeMapper.mapThrowable(seFromStatus)).isEqualTo("FAILED_PRECONDITION"); + + // SocketException wrapped in StorageException + StorageException seFromSocketReset = + new StorageException(0, "Socket Reset", new SocketException("Connection reset")); + assertThat(StorageErrorTypeMapper.mapThrowable(seFromSocketReset)) + .isEqualTo("CONNECTION_RESET"); + } + + @Test + public void mapThrowable_authExceptionWrappingNetworkOrTimeoutCauses() { + class GoogleAuthException extends RuntimeException { + GoogleAuthException(String msg) { + super(msg); + } + + GoogleAuthException(String msg, Throwable cause) { + super(msg, cause); + } + } + + // GoogleAuthException wrapping SocketTimeoutException -> TIMEOUT + GoogleAuthException authTimeout = + new GoogleAuthException("Auth timeout", new SocketTimeoutException("Read timed out")); + assertThat(StorageErrorTypeMapper.mapThrowable(authTimeout)).isEqualTo("TIMEOUT"); + + // GoogleAuthException wrapping UnknownHostException -> DNS_FAILURE + GoogleAuthException authDns = + new GoogleAuthException( + "Auth DNS failure", new UnknownHostException("oauth2.googleapis.com")); + assertThat(StorageErrorTypeMapper.mapThrowable(authDns)).isEqualTo("DNS_FAILURE"); + + // GoogleAuthException wrapping ConnectException -> CONNECTION_REFUSED + GoogleAuthException authRefused = + new GoogleAuthException("Auth connect refused", new ConnectException("Connection refused")); + assertThat(StorageErrorTypeMapper.mapThrowable(authRefused)).isEqualTo("CONNECTION_REFUSED"); + + // GoogleAuthException wrapping SSLHandshakeException -> TLS_FAILURE + GoogleAuthException authTls = + new GoogleAuthException( + "Auth TLS failed", new SSLHandshakeException("TLS handshake failed")); + assertThat(StorageErrorTypeMapper.mapThrowable(authTls)).isEqualTo("TLS_FAILURE"); + + // Multi-level wrapping: GoogleAuthException -> IOException -> SocketTimeoutException -> TIMEOUT + GoogleAuthException nestedAuthTimeout = + new GoogleAuthException( + "Nested timeout", new IOException("IO failed", new SocketTimeoutException("timeout"))); + assertThat(StorageErrorTypeMapper.mapThrowable(nestedAuthTimeout)).isEqualTo("TIMEOUT"); + + // StorageException(401, "Unauthorized", googleAuthExWithTimeout) -> TIMEOUT + StorageException se401WithTimeout = new StorageException(401, "Unauthorized", authTimeout); + assertThat(StorageErrorTypeMapper.mapThrowable(se401WithTimeout)).isEqualTo("TIMEOUT"); + + // GoogleAuthException with no network cause -> AUTHENTICATION_ERROR + GoogleAuthException simpleAuth = new GoogleAuthException("Invalid credentials"); + assertThat(StorageErrorTypeMapper.mapThrowable(simpleAuth)).isEqualTo("AUTHENTICATION_ERROR"); + + // GoogleAuthException wrapping generic IOException("Invalid grant") -> AUTHENTICATION_ERROR + GoogleAuthException authWithGenericIo = + new GoogleAuthException("Grant error", new IOException("Invalid grant")); + assertThat(StorageErrorTypeMapper.mapThrowable(authWithGenericIo)) + .isEqualTo("AUTHENTICATION_ERROR"); + } + + @Test + public void mapThrowable_grpcExceptionsWithNetworkCauses() { + 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"); + + 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"); + + ApiException apiExDns = + new ApiException(sreDns, GrpcStatusCode.of(Status.Code.UNAVAILABLE), false); + assertThat(StorageErrorTypeMapper.mapThrowable(apiExDns)).isEqualTo("DNS_FAILURE"); + + ApiException apiExRefused = + new ApiException( + new ConnectException("Refused"), GrpcStatusCode.of(Status.Code.UNAVAILABLE), 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", true); + } +}