Skip to content

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, invoke throws com.thinkmorestupidless.ankka.core.CommandError, which has message and code. invokeAsync fails its Future with the same exception.
  • In Python, invoke raises ankka.client.CommandError, whose error has message and code.

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.