-
Notifications
You must be signed in to change notification settings - Fork 0
Add typed exception hierarchy #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
154 changes: 154 additions & 0 deletions
154
docs/plans/2026-04-10-typed-exception-hierarchy-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`; 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 | ||
| ├── 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. |
39 changes: 39 additions & 0 deletions
39
src/main/java/ee/bitweb/montonio/sdk/exception/MontonioApiException.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } |
12 changes: 12 additions & 0 deletions
12
src/main/java/ee/bitweb/montonio/sdk/exception/MontonioAuthenticationException.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
12 changes: 12 additions & 0 deletions
12
src/main/java/ee/bitweb/montonio/sdk/exception/MontonioException.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
8 changes: 8 additions & 0 deletions
8
src/main/java/ee/bitweb/montonio/sdk/exception/MontonioNetworkException.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
33 changes: 33 additions & 0 deletions
33
src/main/java/ee/bitweb/montonio/sdk/exception/MontonioValidationException.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| 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) { | ||
| this(field, message, null); | ||
| } | ||
|
|
||
| public MontonioValidationException(String message) { | ||
| 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; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
57 changes: 57 additions & 0 deletions
57
src/test/java/ee/bitweb/montonio/sdk/exception/MontonioApiExceptionTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
35 changes: 35 additions & 0 deletions
35
src/test/java/ee/bitweb/montonio/sdk/exception/MontonioAuthenticationExceptionTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
35 changes: 35 additions & 0 deletions
35
src/test/java/ee/bitweb/montonio/sdk/exception/MontonioExceptionTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.