Lesson 5 - Guided Project: Branching and Retries Together
Welcome to the Guided Project
Lesson 3 wired a real branch. Lesson 4 made a real task fail and retry. This project puts both in one DAG — the same branching zone-and-payment shape Lesson 3 built, with its final load_report step now deliberately unreliable the way Lesson 4’s flaky_ingest was — and verifies the whole thing end to end.
By the end of this project, you will be able to:
- Combine a real branching dependency structure with a real retried task in a single DAG
- Predict, and then confirm, that a retry on one downstream task doesn’t disturb the branch tasks that already finished upstream
- Read a real “Task Tries” panel in the Airflow UI as direct evidence of a retry history, the same fact Lesson 4 confirmed via the REST API
One DAG: A Real Branch, Ending in a Real Retry
# gate: skip
"""Module 3's guided project: one real DAG combining genuine branching
dependencies (ingest/validate feeding two independent summaries that merge
back together) with a deliberately retried final task (load_report fails
its first attempt, succeeds on the second) -- the two ideas Lessons 3 and 4
each demonstrated alone, now together in a single run."""
from datetime import datetime, timedelta
from airflow.decorators import dag, task
WORKDIR = "/opt/airflow/dags/data/work/m3_guided_project"
RAW_CSV = "/opt/airflow/dags/data/yellow_tripdata_sample.csv"
LOAD_MARKER = f"{WORKDIR}/load_report_marker.txt"
@dag(
dag_id="cityflow_guided_project_authoring",
schedule=None,
start_date=datetime(2026, 1, 1),
catchup=False,
tags=["cityflow", "module-3", "guided-project"],
)
def cityflow_guided_project_authoring_dag():
@task
def ingest_taxi_data() -> str:
import os
import pandas as pd
os.makedirs(WORKDIR, exist_ok=True)
raw = pd.read_csv(RAW_CSV)
out_path = f"{WORKDIR}/ingested.csv"
raw.to_csv(out_path, index=False)
print(f"ingested {len(raw):,} raw trips -> {out_path}")
return out_path
@task
def validate_trips(ingested_path: str) -> str:
import pandas as pd
raw = pd.read_csv(ingested_path)
valid = raw[
(raw["passenger_count"] > 0)
& (raw["trip_distance"] > 0)
& (raw["fare_amount"] > 0)
]
out_path = f"{WORKDIR}/validated.csv"
valid.to_csv(out_path, index=False)
print(f"kept {len(valid):,} of {len(raw):,} rows -> {out_path}")
return out_path
@task
def compute_by_payment_type(validated_path: str) -> list:
import pandas as pd
valid = pd.read_csv(validated_path)
summary = (
valid.groupby("payment_type")
.agg(trips=("fare_amount", "size"), avg_fare=("fare_amount", "mean"))
.round(2)
.reset_index()
)
print(summary.to_string(index=False))
return summary.to_dict(orient="records")
@task
def compute_by_passenger_count(validated_path: str) -> list:
import pandas as pd
valid = pd.read_csv(validated_path)
summary = (
valid.groupby("passenger_count")
.agg(trips=("fare_amount", "size"), avg_distance=("trip_distance", "mean"))
.round(2)
.reset_index()
)
print(summary.to_string(index=False))
return summary.to_dict(orient="records")
@task
def merge_summaries(by_payment: list, by_passengers: list) -> dict:
return {"by_payment_type": by_payment, "by_passenger_count": by_passengers}
@task(retries=1, retry_delay=timedelta(seconds=20))
def load_report(merged: dict) -> None:
"""The deliberately-retried task: fails its first real attempt (no
marker file present yet), succeeds on the second."""
import json
import os
if not os.path.exists(LOAD_MARKER):
with open(LOAD_MARKER, "w") as f:
f.write("attempt-1-failed")
raise RuntimeError(
"simulated transient failure writing the report -- Airflow should retry"
)
os.remove(LOAD_MARKER)
out_path = f"{WORKDIR}/guided_project_report.json"
with open(out_path, "w") as f:
json.dump(merged, f, indent=2)
print(f"wrote {out_path} on retry (attempt 2)")
print(json.dumps(merged, indent=2))
ingested = ingest_taxi_data()
validated = validate_trips(ingested)
by_payment = compute_by_payment_type(validated)
by_passengers = compute_by_passenger_count(validated)
merged = merge_summaries(by_payment, by_passengers)
load_report(merged)
cityflow_guided_project_authoring_dag()The shape is Lesson 3’s exactly: validate_trips feeds two independent summary tasks (TaskFlow’s nested calls create the same branch >> to a list did — compute_by_payment_type(validated) and compute_by_passenger_count(validated) both take validated as their only argument, so neither depends on the other). The one new idea is load_report’s retries=1 and its marker-file logic, lifted directly from Lesson 4’s flaky_ingest — the deliberate failure now lives at the very end of the chain, after the branch has already merged.
Triggered End to End
import subprocess
import time
import json
dag_id = "cityflow_guided_project_authoring"
deadline = time.time() + 320
found = False
while time.time() < deadline:
result = subprocess.run(
["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "details", dag_id],
capture_output=True, text=True,
)
if result.returncode == 0:
found = True
break
time.sleep(5)
assert found, f"{dag_id} was never picked up by the scheduler"
subprocess.run(
["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
capture_output=True, text=True, check=True,
)
run_id = f"lesson5_verify_{int(time.time())}"
subprocess.run(
["docker", "exec", "airflow-local-airflow-scheduler-1",
"airflow", "dags", "trigger", dag_id, "--run-id", run_id],
capture_output=True, text=True, check=True,
)
print(f"triggered {dag_id!r} as {run_id!r}")
# retry_delay=20s on load_report means this run genuinely takes >20s
deadline = time.time() + 90
state = "queued"
while state not in ("success", "failed") and time.time() < deadline:
time.sleep(3)
result = subprocess.run(
["docker", "exec", "airflow-local-airflow-scheduler-1",
"airflow", "dags", "list-runs", "-d", dag_id, "--output", "json"],
capture_output=True, text=True, check=True,
)
runs = json.loads(result.stdout)
state = next(r["state"] for r in runs if r["run_id"] == run_id)
print(f"run {run_id!r} reached state: {state}")
assert state == "success", f"expected success, got {state}"
states = subprocess.run(
["docker", "exec", "airflow-local-airflow-scheduler-1",
"airflow", "tasks", "states-for-dag-run", dag_id, run_id],
capture_output=True, text=True, check=True,
)
print(states.stdout)triggered 'cityflow_guided_project_authoring' as 'lesson5_verify_1784885780'
run 'lesson5_verify_1784885780' reached state: success
dag_id | task_id | state | start_date | end_date
===================================+============================+=========+==================================+=================================
cityflow_guided_project_authoring | ingest_taxi_data | success | 2026-07-24T09:36:22.716764+00:00 | 2026-07-24T09:36:23.835079+00:00
cityflow_guided_project_authoring | validate_trips | success | 2026-07-24T09:36:24.810784+00:00 | 2026-07-24T09:36:26.087645+00:00
cityflow_guided_project_authoring | compute_by_payment_type | success | 2026-07-24T09:36:26.250526+00:00 | 2026-07-24T09:36:26.914275+00:00
cityflow_guided_project_authoring | compute_by_passenger_count | success | 2026-07-24T09:36:26.248857+00:00 | 2026-07-24T09:36:26.914258+00:00
cityflow_guided_project_authoring | merge_summaries | success | 2026-07-24T09:36:27.750872+00:00 | 2026-07-24T09:36:27.864773+00:00
cityflow_guided_project_authoring | load_report | success | 2026-07-24T09:36:49.241209+00:00 | 2026-07-24T09:36:49.396302+00:00Two real facts jump out of these timestamps. First, the branch: compute_by_payment_type started at 26.250526, compute_by_passenger_count at 26.248857 — 1.6 milliseconds apart, both genuinely parallel. Second, the retry: merge_summaries finished at 27.864773, but load_report didn’t start until 49.241209 — a real ~21-second gap matching retry_delay=timedelta(seconds=20), the same signature Lesson 4’s retry left behind.
Confirm the retry directly, the same way Lesson 4 did:
import requests
resp = requests.get(
"http://localhost:8080/api/v1/dags/cityflow_guided_project_authoring/dagRuns/"
"lesson5_verify_1784885780/taskInstances/load_report",
auth=("airflow", "airflow"),
)
info = resp.json()
print("load_report state:", info["state"])
print("load_report try_number:", info["try_number"])
assert info["try_number"] == 2, f"expected try_number 2, got {info['try_number']}"
print("confirmed: load_report needed 2 tries to succeed, same as Lesson 4's flaky_ingest")load_report state: success
load_report try_number: 2
confirmed: load_report needed 2 tries to succeed, same as Lesson 4's flaky_ingestThe real merged report, written only once load_report finally succeeded:
{
"by_payment_type": [
{"payment_type": 1, "trips": 153384, "avg_fare": 18.39},
{"payment_type": 2, "trips": 28056, "avg_fare": 18.46},
{"payment_type": 3, "trips": 676, "avg_fare": 16.55},
{"payment_type": 4, "trips": 1517, "avg_fare": 19.27}
],
"by_passenger_count": [
{"passenger_count": 1.0, "trips": 143638, "avg_distance": 3.15},
{"passenger_count": 2.0, "trips": 27038, "avg_distance": 3.84},
{"passenger_count": 3.0, "trips": 5912, "avg_distance": 3.67},
{"passenger_count": 4.0, "trips": 3304, "avg_distance": 3.99},
{"passenger_count": 5.0, "trips": 2263, "avg_distance": 3.00},
{"passenger_count": 6.0, "trips": 1475, "avg_distance": 2.87},
{"passenger_count": 8.0, "trips": 3, "avg_distance": 0.05}
]
}Identical numbers to Lesson 3’s branch_report.json — same real data, same real grouping logic — proving the retry at the end changed when the report was written, not what it contained.
Verified Two More Ways: Real Screenshots
The Graph view: the branch, visibly

The Task Tries panel: the retry, visibly

Three independent readings of the same fact — real elapsed time, the REST API’s try_number, and the UI’s own Task Tries panel — all agree: load_report failed once and succeeded on retry, without disturbing anything upstream of it.
What This Project Actually Proved
Lesson 3 proved a real branch runs its independent tasks in parallel. Lesson 4 proved a real retry is something Airflow’s scheduler does on its own once configured, not something you simulate by hand. This project proved those two facts compose: a branch merging into a task that then fails and retries behaves exactly as each lesson predicted alone — the branch tasks’ timestamps are untouched by the fact that something three steps later needed a second attempt, because nothing about a downstream task’s retry reaches back to redo work that already succeeded.
Key Takeaways
- Branching dependencies and task retries are independent features that compose cleanly — this DAG’s branch tasks succeeded on their first attempt regardless of
load_report’s retry three steps later. - A ~21-second gap between
merge_summariesfinishing andload_reportstarting is real evidence of aretry_delay=timedelta(seconds=20)retry, the same signature Lesson 4 taught you to look for. - The REST API’s
try_numberfield and the UI’s Task Tries panel are two independent readings of the exact same retry history stored in the metadata database — this project’s realtry_number: 2and the screenshot’s red-1/green-2 buttons agree because they’re the same fact, read two ways. - A retried task’s eventual output (here,
guided_project_report.json) is identical to what a first-try success would have produced — retrying changes when work completes, never what the successful attempt computes.
Exercises
Exercise 1 — Open the real Grid view for cityflow_guided_project_authoring in your own browser and click the load_report cell for this run. Confirm you can see the same Task Tries panel this lesson screenshotted, and that clicking try 1 shows the real RuntimeError in its Logs tab while try 2 shows the real wrote ... on retry line.
Exercise 2 — Add retries=1 to compute_by_payment_type as well (with its own separate marker file) so that both a branch task and the final load task retry in the same run. Trigger it and confirm, via try_number on each, that both retries happened independently — one retrying doesn’t force the other to.
Exercise 3 — Using this run’s real task timestamps, calculate how much of the DAG’s ~27-second total wall-clock time was spent purely waiting on load_report’s retry_delay, versus how much was spent on tasks actually doing work. What does that suggest about setting retry_delay too high on a task in a time-sensitive DAG?