Timeouts and Hung Tasks

Concepts covered: Retries and failure handling

A task that never returns stays in running. Its process holds the worker it was given, it keeps reporting that it is alive, and the scheduler has no reason to interfere: as far as Airflow can see, a 9-hour try is a long try. The run stays in running with it, and the log stops after the last line printed. execution_timeout on the task and dagrun_timeout on the DAG put a clock on that.

from datetime import datetime, timedelta

from airflow.sdk import dag, task
from sessions_api import fetch_sessions


@dag(
    schedule="@hourly",
    start_date=datetime(2026, 11, 1),
    catchup=False,
    dagrun_timeout=timedelta(minutes=50),
)
def hourly_sessions():
    @task(
        execution_timeout=timedelta(minutes=15),
        retries=1,
        retry_delay=timedelta(minutes=2),
    )
    def pull_sessions():
        fetch_sessions()

    @task(execution_timeout=timedelta(minutes=10))
    def write_sessions():
        print("sessions written")

    pull_sessions() >> write_sessions()


hourly_sessions()

execution_timeout is a timedelta measured per try from the moment the try starts running. When it elapses, the task runner raises AirflowTaskTimeout inside the task's process, and the try ends as a failure. Retries and callbacks treat it like any other failure: with retries=1, the first timed-out try goes to up_for_retry and on_retry_callback fires if one is set; after retry_delay a second try starts with its own fresh 15 minutes; if that one times out as well, the task reaches failed and on_failure_callback fires. write_sessions becomes upstream_failed and the run is marked failed at 01:32, 32 minutes after it began, rather than never.

ClockEventState after
01:00run for the 00:00 window appears, try 1 of pull_sessions startsrunning
01:1515 minutes elapsed, AirflowTaskTimeout raisedup_for_retry
01:17try 2 starts with a fresh 15 minutesrunning
01:32AirflowTaskTimeout again, no retries leftfailed
01:32write_sessions evaluated under all_successupstream_failed
01:32run state recalculated from its leaffailed
01:50dagrun_timeout would have firednever reached
from datetime import datetime, timedelta
from airflow.sdk import dag, task

@dag(schedule=timedelta(hours=1),
     start_date=datetime(2026, 11, 2))
def hourly_sessions():
    @task(retries=1,
          retry_delay=timedelta(minutes=2),
          execution_timeout=timedelta(minutes=15))
    def pull_sessions(): ...
    @task
    def write_sessions(): ...
    pull_sessions() >> write_sessions()
hourly_sessions()
  • pull_sessions with execution_timeout
  • pull_sessions without execution_timeout
One hour of hourly_sessions, an excerpt of the night: the API never answers, so pull_sessions is cut off at 01:15, retried at 01:17, and failed at 01:32; switch the timeout off and the cell stays running.

The run's own clock

dagrun_timeout is measured from the run's start, the moment it moved to running, which for a catch-up run is long after its logical date. When a run has been running longer than that, the scheduler marks it failed, sets every unfinished task in it to skipped, and fires the DAG's on_failure_callback; a task that was mid-try sees its state change on its next heartbeat and its process is stopped. The parameter is documented as enforced for scheduled runs, so a run you trigger by hand keeps going. In hourly_sessions the 50-minute limit sits behind the task limits as a backstop: the worst case for pull_sessions alone is 32 minutes, write_sessions adds at most 10, so a run that meets the backstop has something wrong that the task limits did not cover.

SettingSet onMeasured fromWhen it elapses
execution_timeouta taskthe start of each trythe try fails with AirflowTaskTimeout; retries and task callbacks apply
dagrun_timeoutthe DAGthe run's start, when it entered runningthe run is marked failed, unfinished tasks are set to skipped, the DAG failure callback fires

Without either setting, a hung task shows up in the grid as a cell that has been light green for hours while its neighbours in older runs took 4 minutes, with a log whose last line is older than the run. Marking it failed from the grid stops the process and ends the try as failed, after which the run's state recalculates and the usual callbacks fire; clearing it starts a fresh try instead. Both are a person doing by hand at 09:00 what a 15-minute limit would have done at 01:15.

Do
  • Give every task that calls out of Airflow an execution_timeout a few times its normal duration
  • Set retries alongside the timeout, so a slow remote gets a second chance before the task fails
  • Set dagrun_timeout longer than the sum of the task limits, so it only fires on what they missed
Avoid
  • Set execution_timeout shorter than a healthy try on a slow day, or the timeout becomes the failure
  • Expect dagrun_timeout to stop a run you triggered by hand
  • Read a long-running cell as progress without opening its log

About this lesson

This section is part of the Task Lifecycle: Intermediate lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.