Skip to main content

Retries, SLAs, and the Alert You Page On

Level 8: Batch Pipelines & Orchestrationmedium28 minretry policytry_number vs max_triesSLA missalert fatiguesilent successconditional aggregationHAVINGmulti-metric reporting

Decide what deserves a page: separate the transient failures a retry absorbed from exhausted retries and SLA misses, then build the on-call triage report unaided.

A retry that worked is not an incident

Every task carries a retry budget. Two fields hold it: try_number, the attempt that produced the row's final state, and max_tries, how many attempts the task was allowed. The canonical setting is about 2 (Research §5.2), and the number is deliberately small. Retries exist to absorb the transient stuff, a network blip, a briefly locked table, a rate limit. They do not exist to hide a bug. Ten retries turn a five-minute failure into an hour of the pipeline quietly re-running broken code before anyone hears about it.

One arithmetic warning before you write the SQL. Real Airflow stores max_tries as the number of RETRIES, not the number of attempts, so a task configured with retries=2 runs as try 1, try 2, try 3 and lands its terminal failure at try_number = max_tries + 1. This lesson's table uses max_tries as the total attempt budget instead, so the exhausted test is the plain try_number = max_tries and you can spend your attention on the reasoning rather than on an off-by-one. When you query a real metadata database, check which convention that version uses before you trust the comparison. In the seed here every task is allowed 2 attempts except load_facts, which is allowed 3 because it writes to a remote warehouse.

That gives you two very different events sharing one word:

  • A retry save. state = 'success' and try_number > 1. Something hiccuped and the machine handled it. This is the system working. It belongs in a weekly report, not on a phone at 3am.
  • An exhausted retry. state = 'failed' and try_number = max_tries. Every attempt was used and the task is still broken. This is the incident.

Not every red task spent its budget, which is why that second condition is not decoration. A task that raises a fail-fast error, Airflow's AirflowFailException, or that an operator marks failed by hand, stops with attempts still on the clock: state = 'failed' and try_number below max_tries. It is a real failure, but the retry policy is not the thing to argue about, because retrying was never going to help.

Alert fatigue is what happens when you treat them the same. Fire on every attempt and the person on call learns that the pipeline channel is noise, and then they miss the one page that mattered.

The SLA miss is a separate failure

A service level agreement on a task is a freshness promise: "load_facts finishes within 8 minutes". Missing it is its own kind of failure, and the thing that makes it worth teaching is that the task can succeed and still miss. A green run that took three times as long still means the dashboard was stale when the business opened. Alerting only on failures misses this entirely.

Table
Four signals from the same task_instances table. Two are noise-adjacent, two are incidents, and only one of the four is visible on a status dashboard.
signalhow you detect itdoes it pagewhy
retry savestate = 'success' AND try_number > 1nothe system already recovered; it belongs in a trend, not a page
exhausted retrystate = 'failed' AND try_number = max_triesyesevery attempt was spent and the data did not land
SLA missduration_sec > sla_secyesthe data is late even though the run is green
silent successstate = 'success' AND rows_written far below normalyesnothing is red and the data is missing anyway
Four signals from the same task_instances table. Two are noise-adjacent, two are incidents, and only one of the four is visible on a status dashboard.

Silent success: green pipeline, empty dashboard

The most-missed production failure has no red anywhere (Research §5.7). Every task returns success, the run is green, and the table is empty or a tenth of its normal size. An upstream API returned an empty page, a filter got tightened, a partition path changed and the read matched nothing. Nothing raised, so nothing alerted.

The only defense is to check the work, not the status. Compare each task's rows_written against what that same task normally writes, and treat a collapse as a failure whatever the state column says. That is the bridge into Module 8.4: a test is a query that returns violating rows, and it passes when it returns none.

Check yourself
You are on call. The daily_sales DAG is green for last night, every task is 'success', and the finance dashboard is empty. What is the FIRST thing you check?

Common mistake: counting failures without splitting exhausted ones from rescued ones. That single number cannot tell you whether your platform is flaky and self-healing or actually broken, and it is the number most junior dashboards show.

Interview nuance: "how many retries would you configure and why" has a real answer: around two, because retries are for transient faults, and anything that survives two attempts needs a human rather than a third attempt. And "the DAG is green but the dashboard is empty" is the silent-success story. Answer it with rows written, compared against that task's own history, walked upstream to the first task whose volume broke.

On a real platform this differs. The detection queries are the same everywhere; the delivery is not. Real alert routing lives in the orchestrator's callbacks and a pager tool such as PagerDuty or Opsgenie, with severity, escalation policies, and on-call rotations attached. The alerts table here is the outbox those callbacks write to, and querying the outbox is how you prove to yourself that your alerting is signal rather than noise. One vendor detail is worth flagging because pre-2025 material gets it wrong: Airflow 3.0 removed task SLAs outright, the sla parameter, sla_miss_callback, and the sla_miss table with it, and Airflow 3.1 replaced them with Deadline Alerts (AIP-86), which fix the old ambiguity about when the clock starts by letting you anchor the deadline to the queue time or the run's start explicitly. Configure an sla= on Airflow 3 and nothing happens. The duration_sec > sla_sec reasoning below is unchanged; only the vendor mechanism moved.

Sample data for this example
CREATE TABLE task_instances (
  run_id       TEXT,
  task_id      TEXT,
  state        TEXT,
  try_number   INTEGER,   -- attempt number that produced this final state
  max_tries    INTEGER,   -- total attempts allowed; try_number = max_tries on a failure means it is exhausted
  duration_sec INTEGER,
  rows_written INTEGER,   -- the number that tells you whether a green task did any work
  sla_sec      INTEGER    -- the task's promised maximum duration
);
INSERT INTO task_instances (run_id, task_id, state, try_number, max_tries, duration_sec, rows_written, sla_sec) VALUES
  ('ds_2026_03_10', 'extract_orders',  'success',         1, 2, 236, 51100, 300),
  ('ds_2026_03_10', 'clean_orders',    'success',         1, 2, 505, 50700, 600),
  ('ds_2026_03_10', 'load_facts',      'failed',          2, 3, 190,     0, 480),
  ('ds_2026_03_10', 'publish_metrics', 'upstream_failed', 1, 2,   0,     0, 180),
  ('ds_2026_03_10', 'run_checks',      'upstream_failed', 1, 2,   0,     0, 120),
  ('ds_2026_03_11', 'extract_orders',  'success',         1, 2, 241, 52310, 300),
  ('ds_2026_03_11', 'clean_orders',    'success',         1, 2, 512, 51880, 600),
  ('ds_2026_03_11', 'load_facts',      'success',         2, 3, 402, 50420, 480),
  ('ds_2026_03_11', 'publish_metrics', 'success',         1, 2,  96,   128, 180),
  ('ds_2026_03_11', 'run_checks',      'success',         1, 2,  63,     0, 120),
  ('ds_2026_03_12', 'extract_orders',  'success',         2, 2, 268, 51940, 300),
  ('ds_2026_03_12', 'clean_orders',    'success',         1, 2, 498, 51500, 600),
  ('ds_2026_03_12', 'load_facts',      'success',         1, 3, 411, 50110, 480),
  ('ds_2026_03_12', 'publish_metrics', 'success',         1, 2, 101,   131, 180),
  ('ds_2026_03_12', 'run_checks',      'success',         1, 2,  58,     0, 120),
  ('ds_2026_03_13', 'extract_orders',  'success',         1, 2, 250, 52760, 300),
  ('ds_2026_03_13', 'clean_orders',    'success',         1, 2, 690, 52300, 600),
  ('ds_2026_03_13', 'load_facts',      'success',         1, 3, 455, 50880, 480),
  ('ds_2026_03_13', 'publish_metrics', 'failed',          2, 2,  78,     0, 180),
  ('ds_2026_03_13', 'run_checks',      'upstream_failed', 1, 2,   0,     0, 120),
  ('ds_2026_03_14', 'extract_orders',  'success',         1, 2, 244, 51230, 300),
  ('ds_2026_03_14', 'clean_orders',    'failed',          2, 2, 640,     0, 600),
  ('ds_2026_03_14', 'load_facts',      'upstream_failed', 1, 3,   0,     0, 480),
  ('ds_2026_03_14', 'publish_metrics', 'upstream_failed', 1, 2,   0,     0, 180),
  ('ds_2026_03_14', 'run_checks',      'upstream_failed', 1, 2,   0,     0, 120),
  ('ds_2026_03_15', 'extract_orders',  'success',         1, 2, 239, 52050, 300),
  ('ds_2026_03_15', 'clean_orders',    'success',         2, 2, 575, 51700, 600),
  ('ds_2026_03_15', 'load_facts',      'success',         1, 3, 468, 50260, 480),
  ('ds_2026_03_15', 'publish_metrics', 'success',         1, 2, 105,   133, 180),
  ('ds_2026_03_15', 'run_checks',      'success',         1, 2,  61,     0, 120),
  ('ds_2026_03_16', 'extract_orders',  'success',         2, 2, 289, 51600, 300),
  ('ds_2026_03_16', 'clean_orders',    'success',         1, 2, 505, 51100, 600),
  ('ds_2026_03_16', 'load_facts',      'success',         1, 3, 402,     0, 480),
  ('ds_2026_03_16', 'publish_metrics', 'success',         1, 2, 214,   129, 180),
  ('ds_2026_03_16', 'run_checks',      'success',         1, 2,  66,     0, 120);
Worked example (SQL)
-- The retries that saved you: succeeded, but not on the first attempt.
SELECT run_id, task_id, try_number, max_tries, duration_sec
FROM task_instances
WHERE state = 'success' AND try_number > 1
ORDER BY run_id, task_id;

Apply

Your turn

The task this lesson builds to.

Write a query that returns every task instance whose retry budget mattered, as (run_id, task_id, try_number, max_tries, outcome), over task_instances(run_id, task_id, state, try_number, max_tries, duration_sec, rows_written, sla_sec).

Include two kinds of row and label each one in outcome: 'exhausted' when the instance failed on its last allowed attempt (state = 'failed' and try_number = max_tries), and 'retry_save' when it succeeded but not on the first attempt (state = 'success' and try_number > 1). Alias the columns exactly, ordered by outcome, then run_id, then task_id.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 3 bonus drills.

Write a query that returns the on-call triage report, as (task_id, runs, exhausted_failures, sla_misses, retry_saves, zero_row_successes), worst first, over task_instances(run_id, task_id, state, try_number, max_tries, duration_sec, rows_written, sla_sec).

One row per task. runs is how many instances of that task exist. exhausted_failures counts instances with state = 'failed' and try_number = max_tries. sla_misses counts instances whose duration_sec exceeds their sla_sec, whatever their state. retry_saves counts instances with state = 'success' and try_number > 1. zero_row_successes counts instances with state = 'success' and rows_written = 0. Alias the columns exactly, ordered by exhausted_failures descending, then sla_misses descending, then task_id.

1 automated check is waiting in the workspace.