Handling Errors
Apart from a write whose outcome is unknown, a view that has not caught up with a write, and a few refusals of values that usually come from a caller, which belong to no category (see below), every failure of ArchitectureKit itself in reading and writing belongs to one of three categories.
Every error of the kit implements the Categorized trait, whose category method returns the category as a Category, or None for a failure that belongs to none. Ask for the category rather than for a concrete failure, so that new failures do not break existing code:
Category::Domainmeans that a business rule refused the command, as withDomainError.Category::Transientmeans that trying again may help, for example if the database can not be reached. A write that fails with it has certainly stored nothing, so trying it again can not store its events twice. A precondition that did not hold is a transient failure of its own variant,Transient::Conflict, since the same command may succeed once it is decided on the state that has moved on.Category::Permanentmeans that trying again will not help, for example if an event could not be decoded, if the data of an event can not be encoded as JSON, if an event does not match the schema of its type, if a subject contains an event type the state has no rule for, if a command declares no precondition, or if the database does not support what a request needs, and has to be upgraded.
Every operation has an error of its own with exactly the cases it can end in, such as ExecuteError for execute, LoadError for load, and WriteError for write, so that a match without a catch-all arm handles all of them. Every one of them turns into architecturekit::Error, so that a function that calls several operations can pass their failures on with ?.
A failure of the database is sorted by what its answer means, the same way for reading and for writing, apart from a write whose outcome is unknown (see below). The database names each error it reports with a code, such as https://docs.eventsourcingdb.io/errors/precondition-violated, and it is the code that decides:
- If a precondition did not hold (
precondition-violated), the failure is aTransient::Conflict. - If the database refuses the request for now, since it is shutting down (
server-shutting-down), its store is read-only (store-read-only), it rebuilds its indexes (indexes-rebuilding), it restores a backup (restore-in-progress), its disk is full (disk-full), its store has failed and waits for a restart (store-failed), or its license has run out (license-invalid) or its store has reached the capacity of the license (capacity-exceeded), the failure belongs toCategory::Transient. Trying again only helps once somebody has dealt with the cause in some of these cases, but reading the state anew never does, so none of them is a conflict. - Every other code means that the request itself is wrong, for example a malformed subject, an event that does not match the schema of its type, or a range of
LowerBound::FromLatestEventthat no event can satisfy, since the latest event of the type comes after the upper bound, and the failure belongs toCategory::Permanent(see Reading Events). So does a code that the kit does not know, from a newer database.
A failure without a code is sorted by what happened:
- If the database can not be reached, if the connection breaks or stalls, if the request did not arrive in time (
408), if the database asks to slow down (429), or if it is unable to answer for now (5xx), the failure belongs toCategory::Transient. So does an error within a stream that a database that only offers the v1 API reports without a code, which it only does when reading is disturbed, for example because it is shutting down. - If the answer does not come from an EventSourcingDB, the failure belongs to
Category::Transientas well, since a proxy in front of the database answers on its own while the database restarts. The message says so, so that a wrong address stands out in the log. - If the client does not send the request at all, since the database does not support what it needs, such as a recursive precondition, which a database that only offers the v1 API can not check, the failure belongs to
Category::Permanent, since only upgrading the database helps. The message says so. A write that fails so has certainly stored nothing, since it was never sent. - If the database rejects the API token (
401), or if it rejects the request itself, for example because it is too large (413), the failure belongs toCategory::Permanent. For a rejected API token, the message says so.
Note that a database that only offers the v1 API, such as EventSourcingDB 1.2, reports its errors without a code. The client gives those that clearly match an error of the v2 API its code, and the kit sorts the others by their status. A 409 without a code is a conflict when writing, unless the database says that an event does not match its schema, which is permanent, and when reading, which knows no preconditions, it is a refusal like any other, and permanent.
match store.execute(&borrow_book(), command).await {
Ok(written) => {
// ...
}
Err(ExecuteError::Decider(refusal)) => {
// A business rule refused the command.
}
Err(ExecuteError::Transient(Transient::Conflict(_))) => {
// A precondition did not hold.
}
Err(ExecuteError::Transient(_)) => {
// Trying again may help.
}
Err(ExecuteError::Permanent(_)) => {
// Trying again will not help.
}
Err(ExecuteError::OutcomeUnknown(_)) => {
// The events may have been stored, so find out before trying again.
}
}
Note that Transient may gain variants, so a match on it needs a catch-all arm, and the arm for Transient::Conflict comes before it. ExecuteError::Decider holds the error of the decider, whose category is the one that error names.
A failure of the database keeps the error of the client as its source, after its category. So source, of the Error trait of the standard library, and a downcast_ref to eventsourcingdb::error::ClientError reach the ClientError::DbApiError with the status, the code, the description, and the details the database gave, for example to log them, as does the cause method of the Failure that the variants of Transient and Permanent hold. It is the category, though, that tells what the failure means, so check for it first. For a conflict, a database that offers the v2 API names the precondition that did not hold in the details, by its index among the preconditions of the command or the write, as in {"precondition":1}, while a database that offers just the v1 API does not (see Guarding Against Concurrent Changes).
An error that your own code returns keeps its type, and names its own category, which is why its type implements Categorized: execute hands the error of a decider over as ExecuteError::Decider, and a run of a projection hands the error of the projection over as ProjectionError::Projection, together with its category. The text of the latter names the event the projection failed at, as in applying event 42: …, and the error itself can be downcast to its type. A panic in a projection, on the other hand, comes back as ProjectionError::Panicked, which belongs to Category::Permanent (see Running Projections). The httpapi module has an error of its own, HttpError, which every error of the kit that a handler meets turns into with ?, apart from NotCaughtUp (see below), and which answers it with a status code (see Mapping Errors to Status Codes and Getting a Single Item).
If the future of a read or a write is dropped, for example by tokio::time::timeout, reading stops, and so does writing, but only before it has begun. This is never a partial success: a read that is dropped returns nothing, rather than part of a state, and dropping the future of execute writes nothing as long as the write has not begun, also while the decider decides. A write that has begun, though, runs on a task of its own until it is finished, since the database may store the events as soon as the request has left, and if it fails after its caller has stopped waiting, the failure is logged with the log crate. What limits the wait for its answer is the stall timeout of the client, which ends the request once the database stops answering (see Configuring the Stall Timeout).
One exception is ExecuteError::OutcomeUnknown, and WriteError::OutcomeUnknown for write, which belongs to no category. It means that a write failed in a way that leaves open whether the database stored the events, which the client decides and reports with ClientError::OutcomeUnknown. The client had connected, but no answer arrived that says that nothing was stored, for example because the connection broke, the answer was cut off, or the database stalled. Or the answer was one that may come after the events were stored, such as 500, or one that does not come from an EventSourcingDB, such as a 502 or 504 of a proxy in front of it. Its text names the write, as in outcome unknown: writing "/books/42", and its source is the error the write failed with, such as a connection that was reset. HttpError answers it with 500 Internal Server Error (see Mapping Errors to Status Codes).
It is not transient, since trying again may store the events twice: with Precondition::unconditionally, the same events are written once more, and with Precondition::on_state_read, execute reads the state anew, which then holds the events, so the precondition holds, and the decider decides once more. So do not try again blindly. Find out first whether the events were stored, for example by reading the subject, or try again only with a precondition that refuses the same events a second time, such as on_event_id with the revision the command was decided on, or on_pristine_subject for a subject that the command creates. Neither execute nor write tries again by itself, not even on a store that decides again on conflicts.
Only a write that certainly stored nothing keeps its category: one for which the client could not connect to the database at all, one that the client did not send, since the database does not support what it needs, and one that the database refused, with a code, such as precondition-violated, or with a status that it only answers before writing anything. That way, a transient failure means for a write as well that trying it again unchanged is safe. The client applies the same rule in every SDK of EventSourcingDB, which errs on the safe side: a request that was cut off after connecting, but before all of it was sent, leaves the outcome open as well. Reading writes nothing, so it is not affected: there, every failure of the database belongs to a category, as described above.
Another exception is NotCaughtUp, which belongs to no category either. It means that a view did not catch up with written events, which is what wait_for_written fails with, whether the time it waits has run out (NotCaughtUp::TimedOut) or the view could not be asked (NotCaughtUp::View, which keeps the error of the view, and with it its category; see Waiting for Written Events). The events were written, so the write has succeeded, and only the view lags behind. So it is not transient, since trying the write again would store its events twice, and not permanent, since the view may still catch up. Its text says why, as in not caught up: the events were written, but the view did not catch up within 5s. It deliberately does not turn into an HttpError with ?: a handler that waits after a command answers with success, since the write has succeeded (see Reading Your Own Writes over HTTP).
One more exception is NotARevision, which belongs to no category either. It means that a value that was meant to be a revision is not one, such as a text that parse refuses, or a number beyond 2^63-1 (see Comparing Revisions). Its text names the value, as in not a revision: "4 2". Such a value usually comes from a request, so HttpError answers it with 400 Bad Request. Everything else that takes a revision, such as a bound of read, Precondition::on_event_id, or wait_for, takes a Revision, which is always one.
The same holds for a text that can not be read as a subject, NotASubject, and for values that can not compose one, SubjectValuesError (see Composing Subjects), which HttpError answers with 400 Bad Request as well, and for a query that expected a single item and found none or several (see Getting a Single Item).
The last exception is ReadError::EmptyRange, which belongs to no category either. It means that the bounds of read leave no room for any event, such as UpperBound::BeforeEvent with the revision 0 (see Reading Events). Its text names the bounds, as in empty range: no event can lie before "0". Such bounds usually come from a request, so HttpError answers it with 400 Bad Request as well.
Note that Permanent::Unverified is a permanent failure of its own variant, which means that an event failed its verification (see Verifying Events). Since that may point to a security incident rather than a mistake, match it before the other permanent failures if you want to treat it differently, for example to raise an alarm. Permanent may gain variants as well, so a match on it needs a catch-all arm.
Note that execute does not try again by itself, unless the store decides again on conflicts (see Guarding Against Concurrent Changes). To try again after a transient failure, call execute again, which is safe, since a write that failed with a transient failure has stored nothing. After an unknown outcome, find out first whether the events were stored (see above).