Handling Errors
Apart from the end of the context, ErrOutcomeUnknown, ErrNotCaughtUp, ErrNotARevision, and ErrEmptyRange, which belong to no category (see below), every failure of ArchitectureKit itself in reading and writing belongs to one of four categories.
Use errors.Is to check for a category rather than for a concrete error:
ErrDomainmeans that a business rule rejected the command, as withNewDomainError.ErrConflictmeans that a precondition did not hold.ErrTransientmeans 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.- Changed in 2.1
ErrPermanentmeans 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, such as a floatNaN, 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 decider returns one, or if it returns an event that isnil.
Changed in 2.0A failure of the database is sorted by what its answer means, the same way for reading and for writing, apart from 409, and apart from a write whose outcome is unknown (see below):
- If the database can not be reached, if the connection breaks, if the database asks to slow down (
429), or if it is unable to answer for now (5xx), for example because it is shutting down, the error belongs toErrTransient. - If the answer does not come from an EventSourcingDB, the error belongs to
ErrTransientas 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 database rejects the API token (
401), or if it rejects the request itself, for example because it is malformed (400) or too large (413), the error belongs toErrPermanent. For a rejected API token, the message says so. - If a precondition did not hold when writing (
409), the error belongs toErrConflict. If an event does not match its schema, which the database answers with the same status, it belongs toErrPermanent. - Reading has no preconditions, so when reading, a
409is a refusal of the request like any other, and the error belongs toErrPermanent. The database answers a read with409if the latest event of the type given toFromLatestEventcomes after the upper bound, and trying again never helps, since new events only move the latest one further away (see Reading Events).
writtenEvents, err := architecturekit.Execute(
// ...
)
switch {
case errors.Is(err, architecturekit.ErrDomain):
// A business rule rejected the command.
case errors.Is(err, architecturekit.ErrConflict):
// A precondition did not hold.
case errors.Is(err, architecturekit.ErrOutcomeUnknown):
// The events may have been stored, so find out before trying again.
case errors.Is(err, architecturekit.ErrTransient):
// Trying again may help.
case errors.Is(err, architecturekit.ErrPermanent):
// Trying again will not help.
}
Note that ErrConflict is a special case of ErrTransient, so check for it first.
Changed in 2.0A failure of the database wraps the error of the client after its category. So errors.As finds the *eventsourcingdb.DBAPIError with the status code and the reason the database gave, for example to log them, and errors.Is finds eventsourcingdb.ErrInvalidServerHeader and eventsourcingdb.ErrHeartbeatTimeout. It is the category, though, that tells what the failure means, so check for it first.
An error that your own code returns, for example from a decider or a projection, passes through unchanged, so it belongs to a category only if you wrap it with one, as NewDomainError does. A panic in a projection, on the other hand, comes back as an error of the category ErrPermanent (see Running Projections). The httpapi and query packages have errors of their own, which StatusFor maps to status codes (see Mapping Errors to Status Codes and Getting a Single Item).
Changed in 2.0If the context ends, reading stops, and so does writing, but only before it has begun. The error then wraps the one of the context, context.Canceled or context.DeadlineExceeded, which belongs to no category, as in architecturekit: reading "/books/42": context canceled. Check for it with errors.Is as well. This is never a partial success: a read that the context cut short fails rather than handing out part of a state, and Execute writes nothing once the context has ended, also if it ends while the decider decides. A write that has begun, though, is finished, since the database may store the events as soon as the request has left, and Execute and Write return its result, the events written or the failure of the write, rather than the error of the context.
Changed in 2.0Another exception is ErrOutcomeUnknown, which belongs to no category either. It means that a write failed in a way that leaves open whether the database stored the events. The request had left, but no complete answer arrived, for example because the connection broke, the answer was cut off or could not be decoded, or a timeout of the client ran out. 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. The error wraps ErrOutcomeUnknown and the error of the client, as in outcome unknown: writing "/books/42": Post "http://localhost:3000/api/v1/write-events": …, which ends in what went wrong, such as connection reset by peer, so check for it with errors.Is, and use errors.As to get at the answer of the database, as for a category. StatusFor maps it to 500 Internal Server Error (see Mapping Errors to Status Codes).
It is not ErrTransient, since trying again may store the events twice: with Unconditionally, the same events are written once more, and with OnStateRead, 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 OnEventID with the revision the command was decided on, or OnPristineSubject 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.
Changed in 2.0Only a write that certainly stored nothing keeps its category: one whose request never left completely, as when the database can not be reached, and one that the database refused before writing anything, with a 4xx, such as 409 for a precondition that did not hold, with 503, which it answers while it shuts down or does not permit writing, or with 507, which it answers once the license has run out. That way, an error of the category ErrTransient means for a write as well that trying it again unchanged is safe. Reading writes nothing, so it is not affected: there, every failure of the database belongs to a category, as described above.
Yet another exception is ErrNotCaughtUp, which belongs to no category either. It means that a view did not catch up with written events, which is what every error of WaitForWritten wraps, whether the time it waits has run out, the context has ended, or the view could not be asked (see Waiting for Written Events). The events were written, so the write has succeeded, and only the view lags behind. So it is not ErrTransient, since trying the write again would store its events twice, and not ErrPermanent, since the view may still catch up. The error wraps ErrNotCaughtUp and says why, as in not caught up: the events were written, but the view did not catch up within 5s, so check for it with errors.Is, before any category, which the cause it keeps may belong to. StatusFor maps it to 500 Internal Server Error (see Mapping Errors to Status Codes), but a handler that waits after a command usually answers it with success, since the write has succeeded.
One more exception is ErrNotARevision, which belongs to no category either. It means that a value that was handed over is not a revision, such as a bound of Read, the event ID of OnEventID, the revision for WaitFor, or a value for CompareRevisions or ParseRevision (see Comparing Revisions). The error wraps ErrNotARevision and names the value, so check for it with errors.Is as well. Such a value usually comes from a request, so StatusFor maps it to 400 Bad Request (see Mapping Errors to Status Codes). Only a function of a view, which refuses the ID of an event it is to apply, wraps it together with ErrPermanent (see Defining Views).
The last exception is ErrEmptyRange, which belongs to no category either. It means that the bounds of Read leave no room for any event, such as BeforeEvent("0") (see Reading Events). The error wraps ErrEmptyRange and names the values, so check for it with errors.Is as well. Such bounds usually come from a request, so StatusFor maps it to 400 Bad Request as well.
Changed in 2.0Note that only the end of the context matches context.Canceled or context.DeadlineExceeded. If an error of the client matches one of them all the same, for example because connecting to the database timed out, which the standard library reports as context.DeadlineExceeded, the failure of the database keeps that error in its message only, so that it does not look like the end of the context. The same goes for ErrOutcomeUnknown, for example after a timeout of the client while a write waited for its answer.
Note that ErrUnverified is a special case of ErrPermanent, which means that an event failed its verification (see Verifying Events). Since that may point to a security incident rather than a mistake, check for it before ErrPermanent if you want to treat it differently, for example to raise an alarm.
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 an error of the category ErrTransient has stored nothing. After ErrOutcomeUnknown, find out first whether the events were stored (see above).