Error codes¶
A handler that refuses a request returns an error effect carrying a message and an error code. The code classifies the refusal so that everything downstream can act on it without reading the message: a calling component receives a typed error, and an HTTP endpoint answers with the matching status without knowing the rule that was broken.
The codes¶
Scala ErrorCode |
Python ErrorCode |
HTTP status | gRPC status | Use it when | Retryable |
|---|---|---|---|---|---|
BadRequest |
BAD_REQUEST |
400 |
INVALID_ARGUMENT |
The request is invalid whatever the state: a quantity of zero, a missing field. The default. | no |
Unauthorized |
UNAUTHORIZED |
401 |
UNAUTHENTICATED |
The caller is not authenticated. | no |
Forbidden |
FORBIDDEN |
403 |
PERMISSION_DENIED |
The caller is authenticated and not allowed. | no |
NotFound |
NOT_FOUND |
404 |
NOT_FOUND |
The thing the request is about does not exist. | no |
Conflict |
CONFLICT |
409 |
FAILED_PRECONDITION |
The request is valid but conflicts with the current state: a cart already checked out. | no |
Timeout |
TIMEOUT |
504 |
DEADLINE_EXCEEDED |
The call did not complete in time. The runtime uses it for a call that exceeded its deadline. | yes |
Unavailable |
UNAVAILABLE |
503 |
UNAVAILABLE |
The target cannot serve right now. The runtime uses it when a component is briefly unreachable, for example while a process-hosted service restarts. | yes |
Internal |
INTERNAL |
500 |
INTERNAL |
Something is wrong that the caller cannot fix. | no |
WorkflowFailed |
WORKFLOW_FAILED |
424 |
ABORTED |
Not a handler's to use: a wait for a workflow's end is answered with it when the workflow failed or was deleted. | no |
NotHome |
NOT_HOME |
421 |
OUT_OF_RANGE |
Not a handler's to use: a replicated entity's copy at this installation cannot answer, and the message names the entity's home. | no |
A retryable code means a caller could reasonably send the same request again unchanged. In Scala,
ErrorCode.retryable answers that.
WorkflowFailed carries details beside its message, which a caller in any language reads without
parsing text: step, the step that failed, when one did; reason; and deleted, "true" for a workflow
that was deleted. In Scala they are error.details and WorkflowEnd.failure(error); in Python
error.details; in TypeScript error.details; in Rust error.details. An HTTP problem body carries them
as details. Over gRPC only the message crosses, so a gRPC caller reads the step and the reason there.
A replicated entity answers NotHome when a write reaches a
follower whose home cannot be reached, when a query reaches an installation outside its replication set,
and when a forwarded write finds that the installation it was sent to is no longer the home; the message
names the home, and a caller may send the request there or try again later. The code is NotHome in
TypeScript and Rust too, 421 Misdirected Request over HTTP and OUT_OF_RANGE over gRPC, which no other
code uses. The same entity answers Conflict to a write while it is in conflict, created at two
installations, and a wait for a handover answers Unavailable when the handover was abandoned or never
asked for, and Timeout when it had not completed in time. A forwarded write that the home did not answer
in time is Timeout, and may or may not have happened.
An agent's guardrail answers Forbidden when it refuses an interaction. A judged guardrail whose check
could not be made because its judgment provider failed answers Unavailable, or Timeout when the
provider did not answer in time, so a caller can tell a refusal from a check that did not happen and retry
only the second. One with no judgment provider configured at all answers Internal, saying what to
configure.
Refusing a request¶
A refusal is a value the handler returns, not an exception it throws. Nothing is persisted for a refused command, and the runtime does not retry it.
def addItem(item: LineItem): Effect[Done] =
if currentState.checkedOut then effects.error("cart is already checked out", ErrorCode.Conflict)
else if item.quantity <= 0 then
effects.error(s"quantity must be greater than zero, was ${item.quantity}")
else effects.persist(ItemAdded(item)).thenReply(_ => Done)
@Command("add-item")
public Effect<Done> addItem(LineItem item) {
if (currentState().checkedOut()) return effects().error(checkedOut());
if (item.quantity() <= 0)
return effects().error("quantity must be greater than zero, was " + item.quantity());
return effects().persist(new CartEvent.ItemAdded(item)).thenDone();
}
@Command("remove-item")
public Effect<Done> removeItem(String productId) {
if (currentState().checkedOut()) return effects().error(checkedOut());
if (!currentState().contains(productId))
return effects().error(Problem.notFound("cart does not contain '" + productId + "'"));
return effects().persist(new CartEvent.ItemRemoved(productId)).thenDone();
}
// A suspend fun, as any handler may be: it waits without holding a thread.
@Command("add-item")
suspend fun addItem(item: LineItem): Effect<Done> = effects {
when {
currentState().checkedOut -> error(checkedOut)
item.quantity <= 0 -> error("quantity must be greater than zero, was ${item.quantity}")
else -> persist(CartEvent.ItemAdded(item)).thenDone()
}
}
@command("add-item")
def add_item(self, item: LineItem) -> EventSourcedEffect[ShoppingCart, ShoppingCartEvent, Done]:
if self.state.checkedOut:
return self.effects.error("cart is already checked out", ErrorCode.CONFLICT)
return self.effects.persist(ItemAdded(item)).then_reply(lambda _: DONE)
addItem(item: LineItem) {
if (this.state.checkedOut) return this.effects.error("cart is already checked out", ErrorCode.Conflict)
if (item.quantity <= 0) return this.effects.error(`quantity must be greater than zero, was ${item.quantity}`)
return this.effects.persist({ type: "ItemAdded", item }).thenReply(() => done)
}
fn add_item(cart: &Cart, item: LineItem, _: &Context) -> Effect<ShoppingCartEvent, Done> {
if cart.checked_out {
return effects::error(ErrorCode::Conflict, "cart is already checked out").into();
}
if item.quantity <= 0 {
let message = format!("quantity must be greater than zero, was {}", item.quantity);
return effects::error(ErrorCode::BadRequest, message).into();
}
effects::persist(ShoppingCartEvent::ItemAdded { item }).then_reply_value(Done)
}
// addItem describes what should happen: a refusal is a returned error and nothing is persisted; an
// accepted item is an event the runtime persists before it replies.
func addItem(_ context.Context, c Cart, item LineItem) (effect[ankka.Done], error) {
if c.CheckedOut {
return effect[ankka.Done]{}, checkedOut
}
if item.Quantity <= 0 {
return effect[ankka.Done]{}, ankka.Refusef(ankka.BadRequest, "quantity must be greater than zero, was %d", item.Quantity)
}
return ankka.Persist[Cart, Event, ankka.Done](ItemAdded{Item: item}).ThenReply(done), nil
}
Without a code, an error is BadRequest.
Receiving a refusal¶
The component client raises the refusal in the caller, with its code intact:
- In Scala,
invokethrowscom.thinkmorestupidless.ankka.core.CommandError, which hasmessageandcode.invokeAsyncfails itsFuturewith the same exception. - In Python,
invokeraisesankka.client.CommandError, whoseerrorhasmessageandcode.
An HTTP endpoint that lets the error propagate answers with the status in the table and a JSON body:
{"status":409,"error":"cart is already checked out"}
An endpoint can also answer with a status of its own: throw HttpProblem(status, message) in Scala, with
helpers such as HttpProblem.notFound, or raise ankka.endpoint.HttpProblem(status, message) in Python.
Refusals and failures¶
A refusal is a modelled outcome: the handler decided, on purpose, to say no. A failure is anything else: a handler that threw, a payload that could not be decoded, a process that did not answer.
| Refusal | Failure | |
|---|---|---|
| Produced by | an error effect | an exception, a crash, a timeout |
| Persists | nothing | nothing |
| Reaches an HTTP caller as | the code's status and the handler's message | 500 with internal error, or 400 for an argument that could not be read |
| Reaches a gRPC caller as | the code's gRPC status and the handler's message | INTERNAL with internal error, or INVALID_ARGUMENT for a request that could not be read |
| Shown on a trace as | a refusal | a fault |
Consumers and timed actions differ: a timed action's failure is retried with backoff, and a consumer that keeps failing does not advance past the message.
In the sidecar protocol¶
The protocol carries a refusal as an Error message with a code from the ErrorCode enum in
payload.proto, which has the same nine values: INTERNAL = 0, BAD_REQUEST = 1, UNAUTHORIZED = 2,
FORBIDDEN = 3, NOT_FOUND = 4, CONFLICT = 5, TIMEOUT = 6, UNAVAILABLE = 7, WORKFLOW_FAILED = 8.
An Error also carries details, a map of text, which only a wait for a workflow's end fills. A fault in the
process is a separate Failure message, never a refusal. See Sidecar protocol.