From e854aca6d48397bcd629e566a0a691734617981f Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Sat, 24 Oct 2026 10:19:47 +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 only after its store has failed, since it then refuses every later write until it is restarted. A write during which storing fails gets an error without a code instead, a status of 500 or a stream that ends before it is complete, which stays an unknown outcome. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Svp844n6XkEuoe7y6zEFtF --- README.md | 2 +- eventsourcingdb/transport/v2/transport.py | 11 +++++++---- tests/test_outcome_unknown.py | 17 ++--------------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 96f875f..35d1284 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Servers without the v2 API report errors without a code. For them, the client as EventSourcingDB never stores a write halfway: the events of a call to `write_events`, or the schema of a call to `register_event_schema`, are either stored completely or not at all. If one of these functions fails in a way that leaves open whether the server stored the write, it raises an `OutcomeUnknownError`, whose `cause` attribute holds the error that the write failed with. This happens, for example, if the server stalls or the connection breaks once the request has a connection, if the server answers with a status such as `500`, `502`, or `504`, if the answer does not come from EventSourcingDB, or if the server ends its answer before it is complete. Before you repeat such a write, find out whether it was stored, for example by reading the events of the subject, or repeat it with a precondition such as `IsSubjectOnEventId` that only holds if it was not stored. -The function raises the error itself, which tells that the write was certainly not stored, only if the client refused the write before sending it, such as with an `IncompatibleServerError`, if the client could not find out which API the server offers, which it asks with a ping before the write where needed (see [Talking to Servers With and Without the v2 API](#talking-to-servers-with-and-without-the-v2-api)), for example because the ping stalled, the server answered it with a status such as `500`, or the answer did not come from EventSourcingDB, if the request could not get a connection at all, such as when the name of the server cannot be resolved, connecting or TLS fails, or the request waits for a free connection for longer than the stall timeout, or if the server refused the write: with an error that it reports within its answer, other than `ErrorCode.STORE_FAILED`, with one of the statuses `400`, `401`, `408`, `413`, `429`, and `503`, or, for servers without the v2 API, with any status `4xx`, `503`, or `507`. +The function raises the error itself, which tells that the write was certainly not stored, only if the client refused the write before sending it, such as with an `IncompatibleServerError`, if the client could not find out which API the server offers, which it asks with a ping before the write where needed (see [Talking to Servers With and Without the v2 API](#talking-to-servers-with-and-without-the-v2-api)), for example because the ping stalled, the server answered it with a status such as `500`, or the answer did not come from EventSourcingDB, if the request could not get a connection at all, such as when the name of the server cannot be resolved, connecting or TLS fails, or the request waits for a free connection for longer than the stall timeout, or if the server refused the write: with an error that it reports within its answer, with one of the statuses `400`, `401`, `408`, `413`, `429`, and `503`, or, for servers without the v2 API, with any status `4xx`, `503`, or `507`. Cancelling a write once it has a connection, for example with `asyncio.timeout` or by cancelling its task, leaves its outcome open as well. It is cancelled as usual nonetheless, so that `asyncio.timeout`, `asyncio.wait_for`, and task groups work as always: the `asyncio.CancelledError` is not wrapped, but marked, and carries a note that says so. To tell whether the outcome of a write is unknown, call `is_outcome_unknown` with the error. It returns `True` for an `OutcomeUnknownError`, for a marked `CancelledError`, and for an error caused by one of them, such as the `TimeoutError` of `asyncio.timeout`: diff --git a/eventsourcingdb/transport/v2/transport.py b/eventsourcingdb/transport/v2/transport.py index 31e453d..b5ea840 100644 --- a/eventsourcingdb/transport/v2/transport.py +++ b/eventsourcingdb/transport/v2/transport.py @@ -7,7 +7,6 @@ from ...errors import ( CustomError, DbApiError, - ErrorCode, InternalError, ServerError, ValidationError, @@ -64,11 +63,15 @@ def is_refusal(error: DbApiError) -> bool: """ Tells whether the server refused a write before it stored anything, as - the v2 API does with every error item except store-failed, and with the - statuses above. + the v2 API does with every error item, and with the statuses above. This + includes store-failed, which the server reports only after its store has + failed, since it then refuses every later write until it is restarted. A + write during which storing fails gets an error without a code instead, a + status of 500 or a stream that ends before it is complete, so its outcome + is unknown. """ if error.status == HTTPStatus.OK: - return error.code != ErrorCode.STORE_FAILED + return True return error.status in REFUSING_STATUSES diff --git a/tests/test_outcome_unknown.py b/tests/test_outcome_unknown.py index 891d285..74fdc2f 100644 --- a/tests/test_outcome_unknown.py +++ b/tests/test_outcome_unknown.py @@ -282,13 +282,12 @@ async def keep_busy(response: web.StreamResponse) -> None: 'license-invalid', 'store-read-only', 'disk-full', + 'store-failed', 'indexes-rebuilding', 'server-shutting-down', ], ) - async def test_raises_an_error_item_of_the_v2_api_other_than_store_failed( - request_: Request, code: str - ) -> None: + async def test_raises_an_error_item_of_the_v2_api(request_: Request, code: str) -> None: error = await outcome_of( request_, StreamServer(answer_with(V2_HEARTBEAT + error_item(code) + V2_END), api='v2') ) @@ -328,18 +327,6 @@ async def test_raises_an_error_of_another_4xx_or_507_of_the_v1_api( class TestWritesWhoseOutcomeIsUnknown: - @staticmethod - @pytest.mark.asyncio - @pytest.mark.parametrize('request_', [write.values[0] for write in WRITES]) - async def test_reports_an_unknown_outcome_for_a_store_that_failed(request_: Request) -> None: - error = await outcome_of( - request_, - StreamServer(answer_with(error_item('store-failed') + V2_END), api='v2'), - ) - - assert_is_outcome_unknown(error, DbApiError) - assert error.cause.code == 'https://docs.eventsourcingdb.io/errors/store-failed' # type: ignore[union-attr] - @staticmethod @pytest.mark.asyncio @pytest.mark.parametrize(('api', 'request_'), WRITES_PER_API)