Skip to content

Result Handling

Every task must return TaskResult[T, TaskError] where:

  • T is the success type
  • TaskError is the error type (always TaskError)

A TaskResult contains exactly one of:

  • ok: The success value (type T)
  • err: The error value (type TaskError)
from horsies import TaskResult, TaskError
@app.task("divide")
def divide(a: int, b: int) -> TaskResult[float, TaskError]:
if b == 0:
return TaskResult(err=TaskError(
error_code="DIVISION_BY_ZERO",
message="Cannot divide by zero",
))
return TaskResult(ok=a / b)
from horsies import Ok, Err
match divide.send(10, 2):
case Ok(handle):
result = handle.get()
if result.is_ok():
print(f"Result: {result.ok_value}")
else:
error = result.err_value
print(f"Error: {error.error_code} - {error.message}")
case Err(send_err):
print(f"Send failed: {send_err.code} - {send_err.message}")

TaskError is a Pydantic model with optional fields:

FieldTypePurpose
error_codestr or BuiltInTaskCodeIdentifies the error type
messagestrHuman-readable description
dataAnyAdditional context (dict, list, etc.)
exceptionBaseException or dictOriginal exception if applicable
return TaskResult(err=TaskError(
error_code="VALIDATION_FAILED",
message="Input validation failed",
data={"field": "email", "reason": "invalid format"},
))

When the library itself encounters an error (not your task code), it uses one of four error code families. The umbrella type alias BuiltInTaskCode covers all of them.

CodeWhen
UNHANDLED_EXCEPTIONTask raised an uncaught exception
TASK_EXCEPTIONTask function threw an exception
WORKER_CRASHEDWorker process died (detected via heartbeat)
BROKER_ERRORBroker failed while retrieving the result
WORKER_RESOLUTION_ERRORWorker couldn’t find the task in registry
WORKER_SERIALIZATION_ERRORResult couldn’t be serialized
RESULT_DESERIALIZATION_ERRORStored result JSON is corrupt or could not be deserialized
WORKFLOW_ENQUEUE_FAILEDWorkflow node failed during enqueue/build
SUBWORKFLOW_LOAD_FAILEDSubworkflow definition could not be loaded
CodeWhen
RETURN_TYPE_MISMATCHReturned value doesn’t match declared type
PYDANTIC_HYDRATION_ERRORTask succeeded but return value could not be rehydrated to declared type
WORKFLOW_CTX_MISSING_IDWorkflow context is missing required ID

Returned by handle.get() for issues retrieving results:

CodeWhen
WAIT_TIMEOUTTimeout elapsed while waiting for result (task may still be running)
TASK_NOT_FOUNDTask ID doesn’t exist in database
WORKFLOW_NOT_FOUNDWorkflow ID doesn’t exist in database
RESULT_NOT_AVAILABLEResult cache is empty for an immediate/sync handle
RESULT_NOT_READYResult not yet available; task is still running
CodeWhen
TASK_CANCELLEDTask was cancelled before completion
WORKFLOW_PAUSEDWorkflow was paused
WORKFLOW_FAILEDWorkflow failed
WORKFLOW_CANCELLEDWorkflow was cancelled
UPSTREAM_SKIPPEDUpstream task in workflow was skipped
SUBWORKFLOW_FAILEDSubworkflow failed
WORKFLOW_SUCCESS_CASE_NOT_METWorkflow success condition was not satisfied

Domain errors: Your task returns an error for business logic reasons.

@app.task("transfer_funds")
def transfer_funds(amount: float) -> TaskResult[str, TaskError]:
if amount <= 0:
# This is a domain error - expected, handled
return TaskResult(err=TaskError(
error_code="INVALID_AMOUNT",
message="Amount must be positive",
))
return TaskResult(ok="Transfer complete")

Built-in errors: Something went wrong in the infrastructure.

# If your task raises an exception:
@app.task("buggy_task")
def buggy_task() -> TaskResult[str, TaskError]:
raise ValueError("Oops") # Becomes UNHANDLED_EXCEPTION

Both cases result in TaskResult(err=...), but the error codes differ.

Horsies validates that your returned value matches the declared type:

@app.task("typed_task")
def typed_task() -> TaskResult[int, TaskError]:
return TaskResult(ok="not an int") # RETURN_TYPE_MISMATCH error

This validation happens at runtime using Pydantic’s TypeAdapter.

TaskResult[None, TaskError] is valid for tasks that don’t return a value:

@app.task("fire_and_forget")
def fire_and_forget() -> TaskResult[None, TaskError]:
do_something()
return TaskResult(ok=None)
result = handle.get()
# Check state
result.is_ok() # True if success
result.is_err() # True if error
# Access values (raises ValueError if wrong state)
result.ok_value # Success value
result.err_value # Error value
# Unwrap shortcuts (raises ValueError if wrong state)
result.unwrap() # Returns ok value, raises if err
result.unwrap_err() # Returns err value, raises if ok

unwrap() and unwrap_err() are equivalent to ok_value and err_value — use whichever reads better in context. Both raise ValueError when called on the wrong variant, so always check is_ok()/is_err() first or use pattern matching.