From 80d5a4c5bd925555744fb9b17920f6eb9bfa5d13 Mon Sep 17 00:00:00 2001 From: Rain Ramm Date: Fri, 10 Apr 2026 05:32:28 +0000 Subject: [PATCH 1/2] Add typed exception hierarchy for SDK error handling Introduces MontonioException base and four specific subtypes: MontonioApiException, MontonioNetworkException, MontonioAuthenticationException, and MontonioValidationException. Each carries typed context fields for consumer-friendly error handling. Closes #13 Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-04-10-typed-exception-hierarchy-design.md | 154 ++++++++++++++++++ .../sdk/exception/MontonioApiException.java | 39 +++++ .../MontonioAuthenticationException.java | 12 ++ .../sdk/exception/MontonioException.java | 12 ++ .../exception/MontonioNetworkException.java | 8 + .../MontonioValidationException.java | 23 +++ .../exception/MontonioApiExceptionTest.java | 57 +++++++ .../MontonioAuthenticationExceptionTest.java | 35 ++++ .../sdk/exception/MontonioExceptionTest.java | 35 ++++ .../MontonioNetworkExceptionTest.java | 37 +++++ .../MontonioValidationExceptionTest.java | 32 ++++ 11 files changed, 444 insertions(+) create mode 100644 docs/plans/2026-04-10-typed-exception-hierarchy-design.md create mode 100644 src/main/java/ee/bitweb/montonio/sdk/exception/MontonioApiException.java create mode 100644 src/main/java/ee/bitweb/montonio/sdk/exception/MontonioAuthenticationException.java create mode 100644 src/main/java/ee/bitweb/montonio/sdk/exception/MontonioException.java create mode 100644 src/main/java/ee/bitweb/montonio/sdk/exception/MontonioNetworkException.java create mode 100644 src/main/java/ee/bitweb/montonio/sdk/exception/MontonioValidationException.java create mode 100644 src/test/java/ee/bitweb/montonio/sdk/exception/MontonioApiExceptionTest.java create mode 100644 src/test/java/ee/bitweb/montonio/sdk/exception/MontonioAuthenticationExceptionTest.java create mode 100644 src/test/java/ee/bitweb/montonio/sdk/exception/MontonioExceptionTest.java create mode 100644 src/test/java/ee/bitweb/montonio/sdk/exception/MontonioNetworkExceptionTest.java create mode 100644 src/test/java/ee/bitweb/montonio/sdk/exception/MontonioValidationExceptionTest.java diff --git a/docs/plans/2026-04-10-typed-exception-hierarchy-design.md b/docs/plans/2026-04-10-typed-exception-hierarchy-design.md new file mode 100644 index 0000000..aca66c3 --- /dev/null +++ b/docs/plans/2026-04-10-typed-exception-hierarchy-design.md @@ -0,0 +1,154 @@ +# Typed Exception Hierarchy Design + +**Issue:** #13 — Typed exception hierarchy +**Date:** 2026-04-10 +**Status:** Proposed + +## Overview + +Define a structured exception hierarchy to distinguish between different failure modes in the Montonio Java SDK. Consumers should be able to catch broadly (`MontonioException`) or handle specific failure types when needed. + +## Design Decisions + +| Decision | Choice | Rationale | +|---------------------------|---------------------------------------------|--------------------------------------------------------------------------------| +| Consumer handling pattern | Mix of broad and fine-grained | Most catch broadly; hierarchy supports specific handling for those who need it | +| API error detail level | Structured typed fields | Cleanest consumer experience; we control the SDK | +| Client-side validation | Fail-fast (single error) | Simpler; YAGNI; can expand to collected errors later | +| Checked vs unchecked | All unchecked (RuntimeException) | Modern Java convention; avoids polluting consumer code | +| Auth exception placement | Sibling of MontonioApiException | Auth errors warrant fundamentally different handling than generic API errors | +| Architecture | Flat hierarchy with context fields | Idiomatic Java, plays well with catch blocks, self-documenting | +| Boilerplate | Lombok `@Getter`; hand-written constructors | Lombok can't delegate to `super()`, so constructors are manual | + +## Exception Hierarchy + +``` +MontonioException (base, extends RuntimeException) +├── MontonioApiException — API returned a non-success response +├── MontonioNetworkException — connection/timeout failure +├── MontonioAuthenticationException — credential or token problem +└── MontonioValidationException — invalid input detected client-side +``` + +All subtypes extend `MontonioException` directly. No deeper inheritance. All subtypes are `final`. + +**Package:** `ee.bitweb.montonio.sdk.exception` + +## Class Specifications + +### MontonioException + +Base exception for all SDK errors. + +```java +package ee.bitweb.montonio.sdk.exception; + +public class MontonioException extends RuntimeException { + public MontonioException(String message) { + super(message); + } + + public MontonioException(String message, Throwable cause) { + super(message, cause); + } +} +``` + +### MontonioApiException + +Thrown when the Montonio API returns a non-success HTTP response. + +```java +@Getter +public final class MontonioApiException extends MontonioException { + private final int statusCode; + private final String errorCode; // nullable — API error code, e.g. "INVALID_AMOUNT" + private final String errorMessage; // nullable — human-readable message from API + + public MontonioApiException(int statusCode, String errorCode, String errorMessage) { + super(formatMessage(statusCode, errorCode, errorMessage)); + this.statusCode = statusCode; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + } +} +``` + +**Message format:** `Montonio API error (HTTP {statusCode}): [{errorCode}] {errorMessage}` +- Omits brackets if `errorCode` is null +- Omits message portion if `errorMessage` is null + +### MontonioNetworkException + +Thrown on connection failures, timeouts, and other I/O errors. + +```java +public final class MontonioNetworkException extends MontonioException { + public MontonioNetworkException(String message, Throwable cause) { + super(message, cause); + } +} +``` + +No extra fields. The `cause` (e.g., `SocketTimeoutException`) tells the story. Constructor always requires a cause. + +### MontonioAuthenticationException + +Thrown on credential or token problems. + +```java +public final class MontonioAuthenticationException extends MontonioException { + public MontonioAuthenticationException(String message) { + super(message); + } + + public MontonioAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} +``` + +No extra fields. The message is descriptive enough to act on (e.g., `"Secret key is not configured for merchant 'EE'"`, `"JWT signature verification failed"`). + +### MontonioValidationException + +Thrown when client-side validation detects invalid input before sending a request. Fail-fast: one exception per validation failure. + +```java +@Getter +public final class MontonioValidationException extends MontonioException { + private final String field; // nullable — not all validations are field-specific + + public MontonioValidationException(String field, String message) { + super(formatMessage(field, message)); + this.field = field; + } + + public MontonioValidationException(String message) { + super("Validation failed: " + message); + this.field = null; + } +} +``` + +**Message format:** +- With field: `Validation failed on field '{field}': {message}` +- Without field: `Validation failed: {message}` + +## Testing Strategy + +Each exception gets a dedicated test class in `src/test/java/ee/bitweb/montonio/sdk/exception/`. + +**For each exception type, test:** +- Construction with all arguments — verify getters return correct values +- Message formatting — verify auto-formatted message matches expected pattern +- Cause chaining — verify `getCause()` propagates correctly +- Nullable fields — verify construction with nulls and graceful message formatting + +**Specific test cases:** +- `MontonioApiException` — message formatting with all fields, null `errorCode`, null `errorMessage`, both null +- `MontonioNetworkException` — cause is always required +- `MontonioValidationException` — message with and without `field` +- `MontonioException` — both constructors (message-only, message+cause) + +No integration tests needed. Target: 100% line coverage. diff --git a/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioApiException.java b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioApiException.java new file mode 100644 index 0000000..2bed4ae --- /dev/null +++ b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioApiException.java @@ -0,0 +1,39 @@ +package ee.bitweb.montonio.sdk.exception; + +import lombok.Getter; + +@Getter +public final class MontonioApiException extends MontonioException { + + private final int statusCode; + private final String errorCode; + private final String errorMessage; + + public MontonioApiException(int statusCode, String errorCode, String errorMessage) { + super(formatMessage(statusCode, errorCode, errorMessage)); + this.statusCode = statusCode; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + } + + private static String formatMessage(int statusCode, String errorCode, String errorMessage) { + StringBuilder sb = new StringBuilder("Montonio API error (HTTP ").append(statusCode).append(")"); + + if (errorCode != null || errorMessage != null) { + sb.append(": "); + } + + if (errorCode != null) { + sb.append("[").append(errorCode).append("]"); + if (errorMessage != null) { + sb.append(" "); + } + } + + if (errorMessage != null) { + sb.append(errorMessage); + } + + return sb.toString(); + } +} diff --git a/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioAuthenticationException.java b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioAuthenticationException.java new file mode 100644 index 0000000..9fee75b --- /dev/null +++ b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioAuthenticationException.java @@ -0,0 +1,12 @@ +package ee.bitweb.montonio.sdk.exception; + +public final class MontonioAuthenticationException extends MontonioException { + + public MontonioAuthenticationException(String message) { + super(message); + } + + public MontonioAuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioException.java b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioException.java new file mode 100644 index 0000000..5854c6f --- /dev/null +++ b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioException.java @@ -0,0 +1,12 @@ +package ee.bitweb.montonio.sdk.exception; + +public class MontonioException extends RuntimeException { + + public MontonioException(String message) { + super(message); + } + + public MontonioException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioNetworkException.java b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioNetworkException.java new file mode 100644 index 0000000..ece45ba --- /dev/null +++ b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioNetworkException.java @@ -0,0 +1,8 @@ +package ee.bitweb.montonio.sdk.exception; + +public final class MontonioNetworkException extends MontonioException { + + public MontonioNetworkException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioValidationException.java b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioValidationException.java new file mode 100644 index 0000000..1e9bdec --- /dev/null +++ b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioValidationException.java @@ -0,0 +1,23 @@ +package ee.bitweb.montonio.sdk.exception; + +import lombok.Getter; + +@Getter +public final class MontonioValidationException extends MontonioException { + + private final String field; + + public MontonioValidationException(String field, String message) { + super(formatMessage(field, message)); + this.field = field; + } + + public MontonioValidationException(String message) { + super("Validation failed: " + message); + this.field = null; + } + + private static String formatMessage(String field, String message) { + return "Validation failed on field '" + field + "': " + message; + } +} diff --git a/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioApiExceptionTest.java b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioApiExceptionTest.java new file mode 100644 index 0000000..e18092e --- /dev/null +++ b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioApiExceptionTest.java @@ -0,0 +1,57 @@ +package ee.bitweb.montonio.sdk.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class MontonioApiExceptionTest { + + @Test + void constructWithAllFields() { + MontonioApiException exception = new MontonioApiException(422, "INVALID_AMOUNT", "The amount must be positive"); + + assertEquals(422, exception.getStatusCode()); + assertEquals("INVALID_AMOUNT", exception.getErrorCode()); + assertEquals("The amount must be positive", exception.getErrorMessage()); + } + + @Test + void messageFormatsWithAllFields() { + MontonioApiException exception = new MontonioApiException(422, "INVALID_AMOUNT", "The amount must be positive"); + + assertEquals("Montonio API error (HTTP 422): [INVALID_AMOUNT] The amount must be positive", exception.getMessage()); + } + + @Test + void messageFormatsWithNullErrorCode() { + MontonioApiException exception = new MontonioApiException(500, null, "Internal server error"); + + assertEquals("Montonio API error (HTTP 500): Internal server error", exception.getMessage()); + assertNull(exception.getErrorCode()); + } + + @Test + void messageFormatsWithNullErrorMessage() { + MontonioApiException exception = new MontonioApiException(400, "BAD_REQUEST", null); + + assertEquals("Montonio API error (HTTP 400): [BAD_REQUEST]", exception.getMessage()); + assertNull(exception.getErrorMessage()); + } + + @Test + void messageFormatsWithBothNullable() { + MontonioApiException exception = new MontonioApiException(503, null, null); + + assertEquals("Montonio API error (HTTP 503)", exception.getMessage()); + assertNull(exception.getErrorCode()); + assertNull(exception.getErrorMessage()); + } + + @Test + void isMontonioException() { + MontonioApiException exception = new MontonioApiException(400, null, null); + + assertEquals(true, exception instanceof MontonioException); + } +} diff --git a/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioAuthenticationExceptionTest.java b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioAuthenticationExceptionTest.java new file mode 100644 index 0000000..3019e11 --- /dev/null +++ b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioAuthenticationExceptionTest.java @@ -0,0 +1,35 @@ +package ee.bitweb.montonio.sdk.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +class MontonioAuthenticationExceptionTest { + + @Test + void constructWithMessage() { + MontonioAuthenticationException exception = new MontonioAuthenticationException("Secret key is not configured"); + + assertEquals("Secret key is not configured", exception.getMessage()); + assertNull(exception.getCause()); + } + + @Test + void constructWithMessageAndCause() { + Throwable cause = new RuntimeException("JWT parse error"); + + MontonioAuthenticationException exception = new MontonioAuthenticationException("JWT signature verification failed", cause); + + assertEquals("JWT signature verification failed", exception.getMessage()); + assertSame(cause, exception.getCause()); + } + + @Test + void isMontonioException() { + MontonioAuthenticationException exception = new MontonioAuthenticationException("test"); + + assertEquals(true, exception instanceof MontonioException); + } +} diff --git a/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioExceptionTest.java b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioExceptionTest.java new file mode 100644 index 0000000..d895117 --- /dev/null +++ b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioExceptionTest.java @@ -0,0 +1,35 @@ +package ee.bitweb.montonio.sdk.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +class MontonioExceptionTest { + + @Test + void constructWithMessage() { + MontonioException exception = new MontonioException("something went wrong"); + + assertEquals("something went wrong", exception.getMessage()); + assertNull(exception.getCause()); + } + + @Test + void constructWithMessageAndCause() { + Throwable cause = new RuntimeException("root cause"); + + MontonioException exception = new MontonioException("something went wrong", cause); + + assertEquals("something went wrong", exception.getMessage()); + assertSame(cause, exception.getCause()); + } + + @Test + void isRuntimeException() { + MontonioException exception = new MontonioException("test"); + + assertEquals(true, exception instanceof RuntimeException); + } +} diff --git a/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioNetworkExceptionTest.java b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioNetworkExceptionTest.java new file mode 100644 index 0000000..560a2fe --- /dev/null +++ b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioNetworkExceptionTest.java @@ -0,0 +1,37 @@ +package ee.bitweb.montonio.sdk.exception; + +import java.net.SocketTimeoutException; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +class MontonioNetworkExceptionTest { + + @Test + void constructWithMessageAndCause() { + Throwable cause = new SocketTimeoutException("Connection timed out"); + + MontonioNetworkException exception = new MontonioNetworkException("Failed to connect to Montonio API", cause); + + assertEquals("Failed to connect to Montonio API", exception.getMessage()); + assertSame(cause, exception.getCause()); + } + + @Test + void causeIsAccessible() { + SocketTimeoutException cause = new SocketTimeoutException("Read timed out"); + + MontonioNetworkException exception = new MontonioNetworkException("Request timed out", cause); + + assertEquals(true, exception.getCause() instanceof SocketTimeoutException); + } + + @Test + void isMontonioException() { + MontonioNetworkException exception = new MontonioNetworkException("error", new RuntimeException()); + + assertEquals(true, exception instanceof MontonioException); + } +} diff --git a/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioValidationExceptionTest.java b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioValidationExceptionTest.java new file mode 100644 index 0000000..7b64586 --- /dev/null +++ b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioValidationExceptionTest.java @@ -0,0 +1,32 @@ +package ee.bitweb.montonio.sdk.exception; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class MontonioValidationExceptionTest { + + @Test + void constructWithFieldAndMessage() { + MontonioValidationException exception = new MontonioValidationException("amount", "must not be null"); + + assertEquals("amount", exception.getField()); + assertEquals("Validation failed on field 'amount': must not be null", exception.getMessage()); + } + + @Test + void constructWithMessageOnly() { + MontonioValidationException exception = new MontonioValidationException("at least one line item is required"); + + assertNull(exception.getField()); + assertEquals("Validation failed: at least one line item is required", exception.getMessage()); + } + + @Test + void isMontonioException() { + MontonioValidationException exception = new MontonioValidationException("test"); + + assertEquals(true, exception instanceof MontonioException); + } +} From 34001e3214eb4070f679a0694ed4f624dc9b4d09 Mon Sep 17 00:00:00 2001 From: Rain Ramm Date: Fri, 10 Apr 2026 05:38:27 +0000 Subject: [PATCH 2/2] Address CodeRabbit review feedback - Fix "hand-written" -> "handwritten" in design doc - Add language hint to fenced code block in design doc - Add cause-aware constructors to MontonioValidationException Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-04-10-typed-exception-hierarchy-design.md | 4 ++-- .../MontonioValidationException.java | 18 +++++++++++---- .../MontonioValidationExceptionTest.java | 23 +++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-04-10-typed-exception-hierarchy-design.md b/docs/plans/2026-04-10-typed-exception-hierarchy-design.md index aca66c3..a1d6b62 100644 --- a/docs/plans/2026-04-10-typed-exception-hierarchy-design.md +++ b/docs/plans/2026-04-10-typed-exception-hierarchy-design.md @@ -18,11 +18,11 @@ Define a structured exception hierarchy to distinguish between different failure | Checked vs unchecked | All unchecked (RuntimeException) | Modern Java convention; avoids polluting consumer code | | Auth exception placement | Sibling of MontonioApiException | Auth errors warrant fundamentally different handling than generic API errors | | Architecture | Flat hierarchy with context fields | Idiomatic Java, plays well with catch blocks, self-documenting | -| Boilerplate | Lombok `@Getter`; hand-written constructors | Lombok can't delegate to `super()`, so constructors are manual | +| Boilerplate | Lombok `@Getter`; handwritten constructors | Lombok can't delegate to `super()`, so constructors are manual | ## Exception Hierarchy -``` +```text MontonioException (base, extends RuntimeException) ├── MontonioApiException — API returned a non-success response ├── MontonioNetworkException — connection/timeout failure diff --git a/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioValidationException.java b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioValidationException.java index 1e9bdec..64b9678 100644 --- a/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioValidationException.java +++ b/src/main/java/ee/bitweb/montonio/sdk/exception/MontonioValidationException.java @@ -8,16 +8,26 @@ public final class MontonioValidationException extends MontonioException { private final String field; public MontonioValidationException(String field, String message) { - super(formatMessage(field, message)); - this.field = field; + this(field, message, null); } public MontonioValidationException(String message) { - super("Validation failed: " + message); - this.field = null; + this(null, message, null); + } + + public MontonioValidationException(String field, String message, Throwable cause) { + super(formatMessage(field, message), cause); + this.field = field; + } + + public MontonioValidationException(String message, Throwable cause) { + this(null, message, cause); } private static String formatMessage(String field, String message) { + if (field == null) { + return "Validation failed: " + message; + } return "Validation failed on field '" + field + "': " + message; } } diff --git a/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioValidationExceptionTest.java b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioValidationExceptionTest.java index 7b64586..c5e9397 100644 --- a/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioValidationExceptionTest.java +++ b/src/test/java/ee/bitweb/montonio/sdk/exception/MontonioValidationExceptionTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; class MontonioValidationExceptionTest { @@ -23,6 +24,28 @@ void constructWithMessageOnly() { assertEquals("Validation failed: at least one line item is required", exception.getMessage()); } + @Test + void constructWithFieldMessageAndCause() { + Throwable cause = new NumberFormatException("not a number"); + + MontonioValidationException exception = new MontonioValidationException("amount", "must be a valid number", cause); + + assertEquals("amount", exception.getField()); + assertEquals("Validation failed on field 'amount': must be a valid number", exception.getMessage()); + assertSame(cause, exception.getCause()); + } + + @Test + void constructWithMessageAndCause() { + Throwable cause = new IllegalArgumentException("parse error"); + + MontonioValidationException exception = new MontonioValidationException("invalid payload", cause); + + assertNull(exception.getField()); + assertEquals("Validation failed: invalid payload", exception.getMessage()); + assertSame(cause, exception.getCause()); + } + @Test void isMontonioException() { MontonioValidationException exception = new MontonioValidationException("test");