From 0679f1e93ab54ba6e8b4f0376f52c7236bd2a852 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Sat, 24 Oct 2026 10:23:32 +0200 Subject: [PATCH] fix: Count store-failed as a refusal of a write, since the server sends it only for writes that it refuses before storing them. The server reports store-failed once its store has failed before, for every later write that it refuses until it is restarted. A write during which storing fails gets an error without a code instead, a 500 or a stream that ends without its end item, which stays an unknown outcome. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Svp844n6XkEuoe7y6zEFtF --- .../eventsourcingdb/ErrorCode.java | 2 +- .../eventsourcingdb/Outcomes.java | 22 ++++++------- .../OutcomeUnknownExceptionTest.java | 32 ++++++++----------- 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ErrorCode.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ErrorCode.java index c49f577..b9191f9 100644 --- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ErrorCode.java +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/ErrorCode.java @@ -208,7 +208,7 @@ public final class ErrorCode { /** The code {@code restore-in-progress}: a restore is in progress, so the database does not accept the request. */ public static final String RESTORE_IN_PROGRESS = BASE_URL + "restore-in-progress"; - /** The code {@code store-failed}: the database failed unexpectedly while it handled the request. */ + /** The code {@code store-failed}: the store has failed before, so it refuses writes until the database is restarted. */ public static final String STORE_FAILED = BASE_URL + "store-failed"; /** The code {@code indexes-rebuilding}: the database is rebuilding its indexes, so it does not answer the request yet. */ diff --git a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Outcomes.java b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Outcomes.java index 7dde8cd..2708aa7 100644 --- a/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Outcomes.java +++ b/eventsourcingdb/src/main/java/io/thenativeweb/eventsourcingdb/Outcomes.java @@ -26,18 +26,18 @@ final class Outcomes { return (status >= 400 && status < 500) || status == 503 || status == 507; }; - // The v2 API refuses a write it accepted with an error in its answer, - // except for store-failed, which reports that storing it failed halfway. - // It refuses a write it did not accept with a status code: 400 for a body - // of the wrong shape, 401 for an invalid API token, 408 for a body that - // did not arrive in time, 413 for a body that is too large, 429 for too - // many requests, and 503 for a server that is shutting down. + // The v2 API refuses a write it accepted with an error in its answer, and + // a write it did not accept with a status code: 400 for a body of the + // wrong shape, 401 for an invalid API token, 408 for a body that did not + // arrive in time, 413 for a body that is too large, 429 for too many + // requests, and 503 for a server that is shutting down. + // The error store-failed is a refusal, too: the server sends it only for + // writes that it refuses before storing them, once its store has failed. + // A write during which storing fails gets a 500, or a stream that ends + // without its end item, instead. static final Predicate V2_REFUSALS = exception -> { - if (exception.status() == 200) { - return !exception.code().equals(ErrorCode.STORE_FAILED); - } - - return Set.of(400, 401, 408, 413, 429, 503).contains(exception.status()); + var status = exception.status(); + return status == 200 || Set.of(400, 401, 408, 413, 429, 503).contains(status); }; private Outcomes() {} diff --git a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownExceptionTest.java b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownExceptionTest.java index de108f6..4d9dafd 100644 --- a/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownExceptionTest.java +++ b/eventsourcingdb/src/test/java/io/thenativeweb/eventsourcingdb/OutcomeUnknownExceptionTest.java @@ -56,6 +56,15 @@ static Stream v2Writes() { return Operations.writes().stream().map(Arguments::of); } + // The server sends store-failed only for writes that it refuses before + // storing them, once its store has failed, so it is a refusal like any + // other error. + static Stream v2RefusalsWithAnError() { + return Operations.writes().stream() + .flatMap(operation -> Stream.of(ErrorCode.PRECONDITION_VIOLATED, ErrorCode.STORE_FAILED) + .map(code -> Arguments.of(operation, code))); + } + private static Client clientWith(FakeServer server) { return new Client(server.url(), "secret", new ClientOptions().withStallTimeout(STALL_TIMEOUT)); } @@ -127,34 +136,21 @@ void reportsAWriteThatTheServerAnswersWithAnotherStatusAsUnknown(ApiVersion api, // The v2 API reports the errors of a write that it accepted within the // stream that answers it, after heartbeats, if the write takes long. @ParameterizedTest - @MethodSource("v2Writes") - void reportsAWriteThatTheV2ApiRefusesWithAnErrorAsNotStored(Operation operation) { - var error = ApiVersion.V2.item( - "error", - "{\"code\":\"" + ErrorCode.PRECONDITION_VIOLATED + "\",\"description\":\"refused\",\"details\":{}}"); + @MethodSource("v2RefusalsWithAnError") + void reportsAWriteThatTheV2ApiRefusesWithAnErrorAsNotStored(Operation operation, String code) { + var error = + ApiVersion.V2.item("error", "{\"code\":\"" + code + "\",\"description\":\"refused\",\"details\":{}}"); try (var server = FakeServer.streaming(ApiVersion.V2, Lines.heartbeat(ApiVersion.V2), error)) { var exception = assertThrows(DbApiException.class, () -> operation.call().accept(clientWith(server))); assertEquals(200, exception.status()); - assertEquals(ErrorCode.PRECONDITION_VIOLATED, exception.code()); + assertEquals(code, exception.code()); assertEquals("refused", exception.description()); } } - // store-failed reports that storing failed halfway, so it may have been - // stored nevertheless. - @ParameterizedTest - @MethodSource("v2Writes") - void reportsAWriteThatTheV2ApiFailedToStoreAsUnknown(Operation operation) { - try (var server = FakeServer.streaming(ApiVersion.V2, Lines.error(ApiVersion.V2))) { - var cause = assertOutcomeUnknown(DbApiException.class, operation, clientWith(server)); - - assertEquals(ErrorCode.STORE_FAILED, cause.code()); - } - } - @ParameterizedTest @MethodSource("v2Writes") void reportsAWriteWhoseAnswerOfTheV2ApiEndsWithoutItsEndItemAsUnknown(Operation operation) {