Skip to content

Retry and Error Handling

django-ray provides at-least-once execution. A task can complete its side effect, lose its result before Django records success, and then run again. Task code must therefore be idempotent.

Configure Retries

# settings.py
DJANGO_RAY = {
    "RAY_ADDRESS": "auto",
    "MAX_TASK_ATTEMPTS": 3,
    "RETRY_BACKOFF_SECONDS": 60,
    "RETRY_EXCEPTION_DENYLIST": [
        "builtins.ValueError",
        "myapp.tasks.PermanentTaskError",
    ],
}

MAX_TASK_ATTEMPTS includes the first attempt. With a 60-second base, retry delays are 60 seconds before attempt two, 120 before attempt three, and 240 before attempt four.

The denylist accepts short built-in names or fully qualified exception paths. Use it only for failures that cannot improve when repeated.

A Complete Retry Example

# myapp/tasks.py
from django.tasks import task


class PermanentTaskError(Exception):
    pass


class TemporaryServiceError(Exception):
    pass


@task(queue_name="default")
def reciprocal(value: float) -> float:
    if value == 0:
        raise PermanentTaskError("zero has no reciprocal")
    if value == 13:
        raise TemporaryServiceError("example transient failure")
    return 1 / value

With the configuration above, PermanentTaskError fails immediately. TemporaryServiceError is retried until it succeeds or exhausts the attempt limit. Uncaught exception type, message, and traceback are stored on RayTaskExecution.

Durable States

State Meaning
QUEUED Waiting for its schedule and a task-manager slot
RUNNING Claimed and submitted or executing
CANCELLING Cancellation requested for running Ray work
CANCELLED Cancellation completed
SUCCEEDED Result recorded successfully
FAILED Permanent failure or retries exhausted
LOST No live owner or reconcilable Ray execution was found

A retry transitions a failed attempt back to QUEUED with run_after set to the calculated backoff time.

Attempt numbers are one-based and increase for both automatic and manual retries; manual retry never resets the counter or consumes a separate budget. Each terminal attempt is copied to TaskAttempt, preserving its state, result reference, and failure diagnostics while the current execution row is prepared for the next run. When that run already owns an accepted canonical schema-v3 terminal progress summary, the same at-most-16-KiB JSON value is archived on TaskAttempt before the current summary is cleared. When lifecycle reconciliation wins first, it derives a terminal envelope from the last accepted running summary under the same row lock. The envelope records the authoritative outer outcome and detail expiry. Success marks every discovered node complete; interrupted outcomes retain the last observed node counters. Complete legacy graphs and topology/detail rows are never copied; malformed, noncanonical, or missing summaries do not block the lifecycle transition. Producer publications reserve one final summary revision so a conflicting terminal report can be replaced by the authoritative row-locked task outcome.

The admin action, operational retry endpoint, and worker retry path use the same row-locked transition service, so a racing retry request is rejected rather than applied twice. Success, permanent failure, timeout, LOST recovery, cancellation, and Ray Job STOPPED reconciliation use that same terminal archival boundary.

Tasks with durable external inputs keep the same immutable input_reference across automatic and manual retries; a retry does not upload a replacement. Corrupt, unauthorized, or unsupported input envelopes fail before user code and are marked non-retryable. Retrieval/storage errors follow normal retry policy because an outage may be transient. Restore a missing object or correct storage configuration before a manual retry. Purged historical inputs cannot be retried unless the same content is restored or reactivated; choose cleanup retention accordingly.

Make Side Effects Idempotent

Use an application-level operation key with a uniqueness constraint. The example below contains both required files.

# myapp/models.py
from django.db import models


class CompletedOperation(models.Model):
    key = models.CharField(max_length=255, unique=True)
    result = models.JSONField()
# myapp/tasks.py
from django.db import transaction
from django.tasks import task

from myapp.models import CompletedOperation


@task(queue_name="default")
def record_total(operation_key: str, values: list[int]) -> dict[str, int]:
    with transaction.atomic():
        completed, created = CompletedOperation.objects.get_or_create(
            key=operation_key,
            defaults={"result": {"total": sum(values)}},
        )
    return {
        "total": int(completed.result["total"]),
        "created": int(created),
    }

For an external payment or webhook, pass the same operation key to the remote service and rely on that service's idempotency guarantee. A local database record alone cannot atomically cover a remote side effect.

Workflow Retries

The outer Django task is the retry boundary for a Ray-native workflow. Internal leaves do not have independent durable retry rows. Retrying the outer task may repeat leaves that succeeded in the previous attempt.

Use:

  • idempotent leaves for external changes;
  • explicit application checkpoints for expensive completed stages;
  • separate Django tasks when each child truly requires its own retry lifecycle;
  • Ray max_retries options only when a leaf-level retry is safe.

Workflow progress is for observation, not recovery. See Ray-Native Workflows.

Lost and Stuck Work

DJANGO_RAY = {
    "RAY_ADDRESS": "ray://ray-head.example:10001",
    "STUCK_TASK_TIMEOUT_SECONDS": 300,
    "WORKER_LEASE_SECONDS": 120,
    "WORKER_HEARTBEAT_SECONDS": 30,
    "TASK_MONITOR_HEARTBEAT_SECONDS": 15,
}

Worker leases detect dead task managers. Task-monitor heartbeats show that a live manager is still reconciling active work. For persisted Ray Job handles, another manager first tries to adopt or reconcile the existing job. Work is marked LOST only after no live owner or recoverable execution remains past the timeout.

Inspect Failures

from django_ray.models import RayTaskExecution, TaskState

failed = RayTaskExecution.objects.filter(state=TaskState.FAILED).order_by("-finished_at")
for execution in failed:
    print(execution.task_id)
    print(execution.error_message)
    print(execution.error_traceback)
    print(execution.attempt_number)

The admin page /admin/django_ray/raytaskexecution/ provides filters, tracebacks, manual retry, and cancellation actions. Prefer the admin action for manual retries; directly resetting model fields is an internal operation and can leave stale Ray handles or result metadata if implemented incompletely.

Denylist Guidance

Usually permanent:

  • validation or schema errors;
  • missing immutable input;
  • permission denial that requires human intervention;
  • unsupported file or protocol versions.

Usually retryable when routed through the normal exception handler:

  • application-level timeouts and temporary network failures;
  • rate limits with a suitable backoff;
  • transient database or object-store availability;
  • Ray node loss.

Do not denylist broad classes such as every TypeError unless application behavior makes that classification intentional.

The worker's configured per-task timeout is different: once the reconciliation loop marks a running task timed out, it is a terminal FAILED state. Retry it explicitly after investigating the timeout.

See Also