From 0d96ba51b28e8cb381f7dd126bbf1dc6a4e624e1 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Tue, 17 Feb 2026 10:24:08 -0800 Subject: [PATCH] Improve error model and serialization --- .../main/java/io/nexusrpc/FailureInfo.java | 29 ++- .../java/io/nexusrpc/OperationException.java | 64 ++++- .../io/nexusrpc/handler/HandlerException.java | 145 ++++++++++- .../java/io/nexusrpc/FailureInfoTest.java | 130 ++++++++++ .../io/nexusrpc/OperationExceptionTest.java | 77 ++++++ .../handler/HandlerExceptionTest.java | 233 ++++++++++++++++++ 6 files changed, 664 insertions(+), 14 deletions(-) create mode 100644 nexus-sdk/src/test/java/io/nexusrpc/FailureInfoTest.java create mode 100644 nexus-sdk/src/test/java/io/nexusrpc/OperationExceptionTest.java create mode 100644 nexus-sdk/src/test/java/io/nexusrpc/handler/HandlerExceptionTest.java diff --git a/nexus-sdk/src/main/java/io/nexusrpc/FailureInfo.java b/nexus-sdk/src/main/java/io/nexusrpc/FailureInfo.java index 89fe997..cc1f291 100644 --- a/nexus-sdk/src/main/java/io/nexusrpc/FailureInfo.java +++ b/nexus-sdk/src/main/java/io/nexusrpc/FailureInfo.java @@ -20,11 +20,17 @@ public static Builder newBuilder(FailureInfo failure) { } private final String message; + private final String stackTrace; private final Map metadata; private final @Nullable String detailsJson; - private FailureInfo(String message, Map metadata, @Nullable String detailsJson) { + private FailureInfo( + String message, + String stackTrace, + Map metadata, + @Nullable String detailsJson) { this.message = message; + this.stackTrace = stackTrace; this.metadata = metadata; this.detailsJson = detailsJson; } @@ -34,6 +40,11 @@ public String getMessage() { return message; } + /** Failure stack trace. */ + public String getStackTrace() { + return stackTrace; + } + /** Failure metadata. */ public Map getMetadata() { return metadata; @@ -50,13 +61,14 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; FailureInfo that = (FailureInfo) o; return Objects.equals(message, that.message) + && Objects.equals(stackTrace, that.stackTrace) && Objects.equals(metadata, that.metadata) && Objects.equals(detailsJson, that.detailsJson); } @Override public int hashCode() { - return Objects.hash(message, metadata, detailsJson); + return Objects.hash(message, stackTrace, metadata, detailsJson); } @Override @@ -65,6 +77,9 @@ public String toString() { + "message='" + message + '\'' + + ", stackTrace='" + + stackTrace + + '\'' + ", metadata=" + metadata + ", details=" @@ -75,6 +90,7 @@ public String toString() { /** Builder for an operation failure. */ public static class Builder { private @Nullable String message; + private @Nullable String stackTrace; private final Map metadata; private @Nullable String detailsJson; @@ -84,6 +100,7 @@ private Builder() { private Builder(FailureInfo failure) { message = failure.message; + stackTrace = failure.stackTrace; metadata = new HashMap<>(failure.metadata); detailsJson = failure.detailsJson; } @@ -111,11 +128,17 @@ public Builder setDetailsJson(@Nullable String detailsJson) { return this; } + /** Set stack trace. */ + public Builder setStackTrace(@Nullable String stackTrace) { + this.stackTrace = stackTrace; + return this; + } + /** Build the operation failure. */ public FailureInfo build() { Objects.requireNonNull(message, "Message required"); return new FailureInfo( - message, Collections.unmodifiableMap(new HashMap<>(metadata)), detailsJson); + message, stackTrace, Collections.unmodifiableMap(new HashMap<>(metadata)), detailsJson); } } } diff --git a/nexus-sdk/src/main/java/io/nexusrpc/OperationException.java b/nexus-sdk/src/main/java/io/nexusrpc/OperationException.java index 3549e3d..cbd6d5a 100644 --- a/nexus-sdk/src/main/java/io/nexusrpc/OperationException.java +++ b/nexus-sdk/src/main/java/io/nexusrpc/OperationException.java @@ -1,20 +1,76 @@ package io.nexusrpc; +import org.jspecify.annotations.Nullable; + /** An operation has failed or was canceled. */ public class OperationException extends Exception { private final OperationState state; - private OperationException(OperationState state, Throwable cause) { - super(cause); + private OperationException(OperationState state, String message, @Nullable Throwable cause) { + super(message, cause); this.state = state; } + /** + * Create a failed operation exception with a message. + * + * @param message The failure message. + * @return The operation exception. + */ + public static OperationException failure(String message) { + return new OperationException(OperationState.FAILED, message, null); + } + + /** + * Create a failed operation exception with a cause. + * + * @param cause The cause of the failure. + * @return The operation exception. + */ public static OperationException failure(Throwable cause) { - return new OperationException(OperationState.FAILED, cause); + return new OperationException(OperationState.FAILED, cause.toString(), cause); + } + + /** + * Create a failed operation exception with a message and cause. + * + * @param message The failure message. + * @param cause The cause of the failure. + * @return The operation exception. + */ + public static OperationException failure(String message, Throwable cause) { + return new OperationException(OperationState.FAILED, message, cause); } + /** + * Create a canceled operation exception with a message. + * + * @param message The cancellation message. + * @return The operation exception. + */ + public static OperationException canceled(String message) { + return new OperationException(OperationState.CANCELED, message, null); + } + + /** + * Create a canceled operation exception with a cause. + * + * @param cause The cause of the cancellation. + * @return The operation exception. + */ public static OperationException canceled(Throwable cause) { - return new OperationException(OperationState.CANCELED, cause); + return new OperationException(OperationState.CANCELED, cause.toString(), cause); + } + + /** + * Create a canceled operation exception with a message and cause. + * + * @param message The cancellation message. + * @param cause The cause of the cancellation. + * @return The operation exception. + */ + public static OperationException canceled(String message, Throwable cause) { + return new OperationException(OperationState.CANCELED, message, cause); } public OperationState getState() { diff --git a/nexus-sdk/src/main/java/io/nexusrpc/handler/HandlerException.java b/nexus-sdk/src/main/java/io/nexusrpc/handler/HandlerException.java index 2946914..af6a3cb 100644 --- a/nexus-sdk/src/main/java/io/nexusrpc/handler/HandlerException.java +++ b/nexus-sdk/src/main/java/io/nexusrpc/handler/HandlerException.java @@ -1,5 +1,6 @@ package io.nexusrpc.handler; +import io.nexusrpc.FailureInfo; import java.util.Arrays; import org.jspecify.annotations.Nullable; @@ -33,36 +34,160 @@ public enum RetryBehavior { private final String rawErrorType; private final ErrorType errorType; private final RetryBehavior retryBehavior; + private final FailureInfo originalFailure; - public HandlerException(ErrorType errorType, String message) { - this(errorType, new RuntimeException(message), RetryBehavior.UNSPECIFIED); + /** + * Create a handler exception with the given error type and cause message. + * + * @param errorType The error type. + * @param causeMessage The cause message. + * @deprecated + */ + public HandlerException(ErrorType errorType, String causeMessage) { + this(errorType, new RuntimeException(causeMessage), RetryBehavior.UNSPECIFIED); + } + + /** + * Create a handler exception with the given error type and cause message. + * + * @param errorType The error type. + * @param causeMessage The cause message. + * @param retryBehavior The retry behavior for this exception. + * @deprecated + */ + public HandlerException(ErrorType errorType, String causeMessage, RetryBehavior retryBehavior) { + this(errorType, new RuntimeException(causeMessage), retryBehavior); } - public HandlerException(ErrorType errorType, String message, RetryBehavior retryBehavior) { - this(errorType, new RuntimeException(message), retryBehavior); + /** + * Create a handler exception with the given error type, message, and cause. + * + * @param errorType The error type. + * @param message The error message. + * @param cause The cause of this exception. + */ + public HandlerException(ErrorType errorType, String message, @Nullable Throwable cause) { + this(errorType, message, cause, RetryBehavior.UNSPECIFIED); } + /** + * Create a handler exception with the given error type and cause. + * + * @param errorType The error type. + * @param cause The cause of this exception. + */ public HandlerException(ErrorType errorType, @Nullable Throwable cause) { this(errorType, cause, RetryBehavior.UNSPECIFIED); } + /** + * Create a handler exception with the given error type, cause, and retry behavior. + * + * @param errorType The error type. + * @param cause The cause of this exception. + * @param retryBehavior The retry behavior for this exception. + */ public HandlerException( ErrorType errorType, @Nullable Throwable cause, RetryBehavior retryBehavior) { - super(cause == null ? "handler error" : "handler error: " + cause.getMessage(), cause); + this( + errorType, + cause == null ? "handler error" : "handler error: " + cause.getMessage(), + cause, + retryBehavior); + } + + /** + * Create a handler exception with the given error type, message, cause, and retry behavior. + * + * @param errorType The error type. + * @param message The error message. + * @param cause The cause of this exception. + * @param retryBehavior The retry behavior for this exception. + */ + public HandlerException( + ErrorType errorType, String message, @Nullable Throwable cause, RetryBehavior retryBehavior) { + this(errorType, message, cause, retryBehavior, null); + } + + /** + * Create a handler exception with the given error type, message, cause, retry behavior, and + * original failure. + * + * @param errorType The error type. + * @param message The error message. + * @param cause The cause of this exception. + * @param retryBehavior The retry behavior for this exception. + * @param originalFailure The original failure information if available. + */ + public HandlerException( + ErrorType errorType, + String message, + @Nullable Throwable cause, + RetryBehavior retryBehavior, + FailureInfo originalFailure) { + super(message, cause); this.rawErrorType = errorType.name(); - this.errorType = errorType; + this.errorType = + Arrays.stream(ErrorType.values()).anyMatch((t) -> t.name().equals(rawErrorType)) + ? ErrorType.valueOf(rawErrorType) + : ErrorType.UNKNOWN; this.retryBehavior = retryBehavior; + this.originalFailure = originalFailure; } + /** + * Create a handler exception with a raw error type string, cause, and retry behavior. + * + * @param rawErrorType The raw error type string. + * @param cause The cause of this exception. + * @param retryBehavior The retry behavior for this exception. + */ public HandlerException( String rawErrorType, @Nullable Throwable cause, RetryBehavior retryBehavior) { - super(cause == null ? "handler error" : "handler error: " + cause.getMessage(), cause); + this( + rawErrorType, + cause == null ? "handler error" : "handler error: " + cause.getMessage(), + cause, + retryBehavior); + } + + /** + * Create a handler exception with a raw error type string, message, cause, and retry behavior. + * + * @param rawErrorType The raw error type string. + * @param message The error message. + * @param cause The cause of this exception. + * @param retryBehavior The retry behavior for this exception. + */ + public HandlerException( + String rawErrorType, String message, @Nullable Throwable cause, RetryBehavior retryBehavior) { + this(rawErrorType, message, cause, retryBehavior, null); + } + + /** + * Create a handler exception with a raw error type string, message, cause, retry behavior, and + * original failure. + * + * @param rawErrorType The raw error type string. + * @param message The error message. + * @param cause The cause of this exception. + * @param retryBehavior The retry behavior for this exception. + * @param originalFailure The original failure information if available. + */ + public HandlerException( + String rawErrorType, + String message, + @Nullable Throwable cause, + RetryBehavior retryBehavior, + @Nullable FailureInfo originalFailure) { + super(message, cause); this.rawErrorType = rawErrorType; this.errorType = Arrays.stream(ErrorType.values()).anyMatch((t) -> t.name().equals(rawErrorType)) ? ErrorType.valueOf(rawErrorType) : ErrorType.UNKNOWN; this.retryBehavior = retryBehavior; + this.originalFailure = originalFailure; } /** @@ -86,6 +211,12 @@ public RetryBehavior getRetryBehavior() { return retryBehavior; } + /** Original FailureInfo if available */ + @Nullable + public FailureInfo getOriginalFailure() { + return originalFailure; + } + public boolean isRetryable() { if (retryBehavior != RetryBehavior.UNSPECIFIED) { return retryBehavior == RetryBehavior.RETRYABLE; diff --git a/nexus-sdk/src/test/java/io/nexusrpc/FailureInfoTest.java b/nexus-sdk/src/test/java/io/nexusrpc/FailureInfoTest.java new file mode 100644 index 0000000..4a31617 --- /dev/null +++ b/nexus-sdk/src/test/java/io/nexusrpc/FailureInfoTest.java @@ -0,0 +1,130 @@ +package io.nexusrpc; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +public class FailureInfoTest { + @Test + void builderWithAllFields() { + FailureInfo failure = + FailureInfo.newBuilder() + .setMessage("Test failure message") + .setStackTrace("at Test.method(Test.java:10)") + .putMetadata("key1", "value1") + .putMetadata("key2", "value2") + .setDetailsJson("{\"detail\": \"value\"}") + .build(); + + assertEquals("Test failure message", failure.getMessage()); + assertEquals("at Test.method(Test.java:10)", failure.getStackTrace()); + assertEquals(2, failure.getMetadata().size()); + assertEquals("value1", failure.getMetadata().get("key1")); + assertEquals("value2", failure.getMetadata().get("key2")); + assertEquals("{\"detail\": \"value\"}", failure.getDetailsJson()); + } + + @Test + void builderWithNullStackTrace() { + FailureInfo failure = + FailureInfo.newBuilder().setMessage("Test failure message").setStackTrace(null).build(); + + assertEquals("Test failure message", failure.getMessage()); + assertNull(failure.getStackTrace()); + } + + @Test + void builderWithoutStackTrace() { + FailureInfo failure = FailureInfo.newBuilder().setMessage("Test failure message").build(); + + assertEquals("Test failure message", failure.getMessage()); + assertNull(failure.getStackTrace()); + } + + @Test + void builderRequiresMessage() { + assertThrows(NullPointerException.class, () -> FailureInfo.newBuilder().build()); + } + + @Test + void builderFromExistingFailure() { + FailureInfo original = + FailureInfo.newBuilder() + .setMessage("Original message") + .setStackTrace("Original stack trace") + .putMetadata("key", "value") + .setDetailsJson("{\"original\": true}") + .build(); + + FailureInfo copied = + FailureInfo.newBuilder(original) + .setMessage("Updated message") + .putMetadata("newKey", "newValue") + .build(); + + assertEquals("Updated message", copied.getMessage()); + assertEquals("Original stack trace", copied.getStackTrace()); + assertEquals(2, copied.getMetadata().size()); + assertEquals("value", copied.getMetadata().get("key")); + assertEquals("newValue", copied.getMetadata().get("newKey")); + assertEquals("{\"original\": true}", copied.getDetailsJson()); + } + + @Test + void metadataIsImmutable() { + FailureInfo failure = + FailureInfo.newBuilder().setMessage("Test").putMetadata("key", "value").build(); + + assertThrows( + UnsupportedOperationException.class, () -> failure.getMetadata().put("new", "value")); + } + + @Test + void equalsAndHashCode() { + FailureInfo failure1 = + FailureInfo.newBuilder() + .setMessage("message") + .setStackTrace("stack") + .putMetadata("key", "value") + .setDetailsJson("{}") + .build(); + + FailureInfo failure2 = + FailureInfo.newBuilder() + .setMessage("message") + .setStackTrace("stack") + .putMetadata("key", "value") + .setDetailsJson("{}") + .build(); + + FailureInfo failure3 = + FailureInfo.newBuilder() + .setMessage("different") + .setStackTrace("stack") + .putMetadata("key", "value") + .build(); + + assertEquals(failure1, failure2); + assertEquals(failure1.hashCode(), failure2.hashCode()); + assertNotEquals(failure1, failure3); + assertNotEquals(failure1.hashCode(), failure3.hashCode()); + } + + @Test + void toStringContainsAllFields() { + FailureInfo failure = + FailureInfo.newBuilder() + .setMessage("test message") + .setStackTrace("test stack") + .putMetadata("key", "value") + .setDetailsJson("{}") + .build(); + + String str = failure.toString(); + assertTrue(str.contains("test message")); + assertTrue(str.contains("test stack")); + assertTrue(str.contains("key")); + assertTrue(str.contains("value")); + assertTrue(str.contains("{}")); + } +} diff --git a/nexus-sdk/src/test/java/io/nexusrpc/OperationExceptionTest.java b/nexus-sdk/src/test/java/io/nexusrpc/OperationExceptionTest.java new file mode 100644 index 0000000..188939e --- /dev/null +++ b/nexus-sdk/src/test/java/io/nexusrpc/OperationExceptionTest.java @@ -0,0 +1,77 @@ +package io.nexusrpc; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +public class OperationExceptionTest { + @Test + void failureWithMessage() { + OperationException ex = OperationException.failure("Test failure"); + + assertEquals("Test failure", ex.getMessage()); + assertNull(ex.getCause()); + assertEquals(OperationState.FAILED, ex.getState()); + } + + @Test + void failureWithCause() { + RuntimeException cause = new RuntimeException("Root cause"); + OperationException ex = OperationException.failure(cause); + + assertEquals(cause.toString(), ex.getMessage()); + assertEquals(cause, ex.getCause()); + assertEquals(OperationState.FAILED, ex.getState()); + } + + @Test + void failureWithMessageAndCause() { + RuntimeException cause = new RuntimeException("Root cause"); + OperationException ex = OperationException.failure("Custom message", cause); + + assertEquals("Custom message", ex.getMessage()); + assertEquals(cause, ex.getCause()); + assertEquals(OperationState.FAILED, ex.getState()); + } + + @Test + void canceledWithMessage() { + OperationException ex = OperationException.canceled("Test cancellation"); + + assertEquals("Test cancellation", ex.getMessage()); + assertNull(ex.getCause()); + assertEquals(OperationState.CANCELED, ex.getState()); + } + + @Test + void canceledWithCause() { + RuntimeException cause = new RuntimeException("Cancellation reason"); + OperationException ex = OperationException.canceled(cause); + + assertEquals(cause.toString(), ex.getMessage()); + assertEquals(cause, ex.getCause()); + assertEquals(OperationState.CANCELED, ex.getState()); + } + + @Test + void canceledWithMessageAndCause() { + RuntimeException cause = new RuntimeException("Cancellation reason"); + OperationException ex = OperationException.canceled("Custom cancellation message", cause); + + assertEquals("Custom cancellation message", ex.getMessage()); + assertEquals(cause, ex.getCause()); + assertEquals(OperationState.CANCELED, ex.getState()); + } + + @Test + void exceptionChaining() { + Exception rootCause = new Exception("Root cause"); + RuntimeException intermediateCause = new RuntimeException("Intermediate", rootCause); + OperationException ex = OperationException.failure("Operation failed", intermediateCause); + + assertEquals("Operation failed", ex.getMessage()); + assertEquals(intermediateCause, ex.getCause()); + assertEquals(rootCause, ex.getCause().getCause()); + assertEquals(OperationState.FAILED, ex.getState()); + } +} diff --git a/nexus-sdk/src/test/java/io/nexusrpc/handler/HandlerExceptionTest.java b/nexus-sdk/src/test/java/io/nexusrpc/handler/HandlerExceptionTest.java new file mode 100644 index 0000000..f3c2660 --- /dev/null +++ b/nexus-sdk/src/test/java/io/nexusrpc/handler/HandlerExceptionTest.java @@ -0,0 +1,233 @@ +package io.nexusrpc.handler; + +import static org.junit.jupiter.api.Assertions.*; + +import io.nexusrpc.FailureInfo; +import org.junit.jupiter.api.Test; + +public class HandlerExceptionTest { + @Test + void constructorWithErrorTypeAndMessage() { + @SuppressWarnings("deprecation") + HandlerException ex = + new HandlerException(HandlerException.ErrorType.BAD_REQUEST, "Invalid input"); + + assertEquals("Invalid input", ex.getCause().getMessage()); + assertEquals(HandlerException.ErrorType.BAD_REQUEST, ex.getErrorType()); + assertEquals(HandlerException.RetryBehavior.UNSPECIFIED, ex.getRetryBehavior()); + assertNull(ex.getOriginalFailure()); + } + + @SuppressWarnings("deprecation") + @Test + void constructorWithErrorTypeMessageAndRetryBehavior() { + HandlerException ex = + new HandlerException( + HandlerException.ErrorType.INTERNAL, + "Server error", + HandlerException.RetryBehavior.NON_RETRYABLE); + + assertEquals("Server error", ex.getCause().getMessage()); + assertEquals(HandlerException.ErrorType.INTERNAL, ex.getErrorType()); + assertEquals(HandlerException.RetryBehavior.NON_RETRYABLE, ex.getRetryBehavior()); + assertFalse(ex.isRetryable()); + } + + @Test + void constructorWithErrorTypeMessageAndCause() { + RuntimeException cause = new RuntimeException("Root cause"); + HandlerException ex = + new HandlerException(HandlerException.ErrorType.INTERNAL, "Handler error", cause); + + assertEquals("Handler error", ex.getMessage()); + assertEquals(cause, ex.getCause()); + assertEquals(HandlerException.ErrorType.INTERNAL, ex.getErrorType()); + assertEquals(HandlerException.RetryBehavior.UNSPECIFIED, ex.getRetryBehavior()); + } + + @Test + void constructorWithErrorTypeAndCause() { + RuntimeException cause = new RuntimeException("Root cause"); + HandlerException ex = new HandlerException(HandlerException.ErrorType.UNAVAILABLE, cause); + + assertEquals("handler error: Root cause", ex.getMessage()); + assertEquals(cause, ex.getCause()); + assertEquals(HandlerException.ErrorType.UNAVAILABLE, ex.getErrorType()); + assertTrue(ex.isRetryable()); + } + + @Test + void constructorWithErrorTypeAndNullCause() { + HandlerException ex = + new HandlerException(HandlerException.ErrorType.NOT_FOUND, (Throwable) null); + + assertEquals("handler error", ex.getMessage()); + assertNull(ex.getCause()); + assertEquals(HandlerException.ErrorType.NOT_FOUND, ex.getErrorType()); + assertFalse(ex.isRetryable()); + } + + @Test + void constructorWithErrorTypeCauseAndRetryBehavior() { + RuntimeException cause = new RuntimeException("Error"); + HandlerException ex = + new HandlerException( + HandlerException.ErrorType.BAD_REQUEST, + cause, + HandlerException.RetryBehavior.RETRYABLE); + + assertEquals("handler error: Error", ex.getMessage()); + assertEquals(cause, ex.getCause()); + assertTrue(ex.isRetryable()); + } + + @Test + void constructorWithAllParameters() { + RuntimeException cause = new RuntimeException("Cause"); + FailureInfo originalFailure = + FailureInfo.newBuilder() + .setMessage("Original failure") + .setStackTrace("at Test.method(Test.java:1)") + .build(); + + HandlerException ex = + new HandlerException( + HandlerException.ErrorType.INTERNAL, + "Custom message", + cause, + HandlerException.RetryBehavior.NON_RETRYABLE, + originalFailure); + + assertEquals("Custom message", ex.getMessage()); + assertEquals(cause, ex.getCause()); + assertEquals(HandlerException.ErrorType.INTERNAL, ex.getErrorType()); + assertEquals(HandlerException.RetryBehavior.NON_RETRYABLE, ex.getRetryBehavior()); + assertEquals(originalFailure, ex.getOriginalFailure()); + assertFalse(ex.isRetryable()); + } + + @Test + void constructorWithRawErrorType() { + HandlerException ex = + new HandlerException( + "CUSTOM_ERROR_TYPE", + new RuntimeException("Error"), + HandlerException.RetryBehavior.UNSPECIFIED); + + assertEquals("CUSTOM_ERROR_TYPE", ex.getRawErrorType()); + assertEquals(HandlerException.ErrorType.UNKNOWN, ex.getErrorType()); + assertTrue(ex.isRetryable()); + } + + @Test + void constructorWithRawErrorTypeAndMessage() { + RuntimeException cause = new RuntimeException("Cause"); + HandlerException ex = + new HandlerException( + "CUSTOM_TYPE", "Custom message", cause, HandlerException.RetryBehavior.RETRYABLE); + + assertEquals("CUSTOM_TYPE", ex.getRawErrorType()); + assertEquals(HandlerException.ErrorType.UNKNOWN, ex.getErrorType()); + assertEquals("Custom message", ex.getMessage()); + assertTrue(ex.isRetryable()); + } + + @Test + void constructorWithRawErrorTypeAndOriginalFailure() { + RuntimeException cause = new RuntimeException("Error"); + FailureInfo originalFailure = + FailureInfo.newBuilder().setMessage("Original").setStackTrace("stack").build(); + + HandlerException ex = + new HandlerException( + "MY_ERROR", + "Message", + cause, + HandlerException.RetryBehavior.UNSPECIFIED, + originalFailure); + + assertEquals("MY_ERROR", ex.getRawErrorType()); + assertEquals(originalFailure, ex.getOriginalFailure()); + assertEquals("Original", ex.getOriginalFailure().getMessage()); + } + + @Test + void retryBehaviorOverridesDefaultForRetryableError() { + // INTERNAL is retryable by default + @SuppressWarnings("deprecation") + HandlerException ex = + new HandlerException( + HandlerException.ErrorType.INTERNAL, + "Error", + HandlerException.RetryBehavior.NON_RETRYABLE); + assertFalse(ex.isRetryable()); + } + + @Test + void retryBehaviorOverridesDefaultForNonRetryableError() { + // BAD_REQUEST is non-retryable by default + @SuppressWarnings("deprecation") + HandlerException ex = + new HandlerException( + HandlerException.ErrorType.BAD_REQUEST, + "Error", + HandlerException.RetryBehavior.RETRYABLE); + assertTrue(ex.isRetryable()); + } + + @Test + void defaultRetryBehaviorForRetryableErrors() { + HandlerException.ErrorType[] retryableTypes = { + HandlerException.ErrorType.RESOURCE_EXHAUSTED, + HandlerException.ErrorType.INTERNAL, + HandlerException.ErrorType.UNAVAILABLE, + HandlerException.ErrorType.UPSTREAM_TIMEOUT, + HandlerException.ErrorType.UNKNOWN + }; + + for (HandlerException.ErrorType type : retryableTypes) { + HandlerException ex = new HandlerException(type, new RuntimeException("error")); + assertTrue(ex.isRetryable(), type + " should be retryable by default"); + } + } + + @Test + void defaultRetryBehaviorForNonRetryableErrors() { + HandlerException.ErrorType[] nonRetryableTypes = { + HandlerException.ErrorType.BAD_REQUEST, + HandlerException.ErrorType.UNAUTHENTICATED, + HandlerException.ErrorType.UNAUTHORIZED, + HandlerException.ErrorType.NOT_FOUND, + HandlerException.ErrorType.NOT_IMPLEMENTED + }; + + for (HandlerException.ErrorType type : nonRetryableTypes) { + HandlerException ex = new HandlerException(type, new RuntimeException("error")); + assertFalse(ex.isRetryable(), type + " should not be retryable by default"); + } + } + + @Test + void unknownRawErrorTypeDefaultsToUnknownEnum() { + HandlerException ex = + new HandlerException( + "COMPLETELY_UNKNOWN_ERROR_TYPE", + new RuntimeException("error"), + HandlerException.RetryBehavior.UNSPECIFIED); + + assertEquals("COMPLETELY_UNKNOWN_ERROR_TYPE", ex.getRawErrorType()); + assertEquals(HandlerException.ErrorType.UNKNOWN, ex.getErrorType()); + } + + @Test + void knownRawErrorTypeMatchesEnum() { + HandlerException ex = + new HandlerException( + "BAD_REQUEST", + new RuntimeException("error"), + HandlerException.RetryBehavior.UNSPECIFIED); + + assertEquals("BAD_REQUEST", ex.getRawErrorType()); + assertEquals(HandlerException.ErrorType.BAD_REQUEST, ex.getErrorType()); + } +}