Error Handling, Retries, and Dead Letter Queues
Classifying errors, exponential backoff with jitter, circuit breakers, DLQ design, and building pipelines that recover automatically.
The Gap Between a Pipeline That Works and One That Survives Production
A pipeline that handles the happy path is not a production pipeline. Production has network timeouts at 3 AM, API rate limits during traffic spikes, one malformed row in a batch of 50,000, Snowflake warehouse auto-suspended when the pipeline starts, a source database that returns 503 for 4 minutes during a deploy, and a vendor CSV that arrives with an entirely wrong schema once a month.
The difference between a pipeline that handles these gracefully and one that pages you at 3 AM is a well-designed error handling strategy. This module builds every layer of it — classification, retries, circuit breakers, dead letter queues, and alerting — around one running example: FreshCart’s payments ingestion pipeline.
Transient vs Permanent Errors — The Classification That Determines Everything
The single most important decision in error handling is whether to retry. Retrying a transient error recovers the pipeline automatically. Retrying a permanent error wastes time, consumes resources, and delays the alert that would trigger human intervention.
The error taxonomy for data pipelines
| Error type | Examples | Retry? | Action |
|---|---|---|---|
| Network timeout | requests.Timeout, psycopg2.OperationalError, ConnectionResetError | ✓ Yes — fixed interval or backoff | Retry up to N times. Alert if all retries exhausted. |
| Rate limit (429) | HTTP 429 Too Many Requests | ✓ Yes — after Retry-After delay | Read Retry-After header. Wait exact amount. Then retry. |
| Server error (5xx) | HTTP 500, 502, 503, 504 | ✓ Yes — with exponential backoff | Backoff: 2s, 4s, 8s, 16s, 32s. Alert if 3+ consecutive 5xx. |
| Database lock/deadlock | psycopg2.errors.DeadlockDetected | ✓ Yes — immediately or short delay | Retry the transaction immediately (deadlocks resolve on retry). |
| Auth failure (401) | HTTP 401 Unauthorized | ✗ No — credentials are wrong | Alert immediately. Do not retry — credentials will not fix themselves. |
| Forbidden (403) | HTTP 403 Forbidden | ✗ No — permissions issue | Alert immediately. Investigate permissions. |
| Not found (404) | HTTP 404 Not Found | ✗ No — resource does not exist | Log warning. Skip this record. The resource was deleted. |
| Schema mismatch | Column "order_amount" does not exist, unexpected type | ✗ No — structural issue | Alert immediately. Pipeline cannot proceed without schema fix. |
| Data validation failure | NULL in required field, negative amount | ✗ No — data is genuinely invalid | Write row to DLQ. Continue with rest of batch. Alert if DLQ rate high. |
| OOM / memory error | MemoryError, Container OOMKilled | ⚡ Maybe — with smaller batch size | Reduce batch size. If still OOM: alert — resource issue. |
One classifier function, used everywhere
import requests
import psycopg2
class ErrorClassification:
RETRY_IMMEDIATELY = 'retry_immediately' # retry at once (deadlock)
RETRY_BACKOFF = 'retry_backoff' # retry after exponential backoff
RETRY_AFTER_DELAY = 'retry_after_delay' # retry after specific delay (rate limit)
PERMANENT_FAILURE = 'permanent_failure' # do not retry, alert
ROW_LEVEL_FAILURE = 'row_level_failure' # reject row to DLQ, continue
def classify_error(exc: Exception, response=None) -> tuple[str, str]:
"""Classify an exception into a handling category. Returns (classification, reason)."""
if response is not None:
status = response.status_code
if status == 429:
retry_after = response.headers.get('Retry-After', '60')
return ErrorClassification.RETRY_AFTER_DELAY, f'Rate limited — Retry-After: {retry_after}s'
if status in (500, 502, 503, 504):
return ErrorClassification.RETRY_BACKOFF, f'Server error {status} — transient'
if status == 401:
return ErrorClassification.PERMANENT_FAILURE, 'Authentication failed (401) — check credentials'
if status == 403:
return ErrorClassification.PERMANENT_FAILURE, 'Forbidden (403) — check permissions'
if status == 404:
return ErrorClassification.ROW_LEVEL_FAILURE, 'Resource not found (404) — skip this record'
if 400 <= status < 500:
return ErrorClassification.PERMANENT_FAILURE, f'Client error {status} — fix request before retrying'
if isinstance(exc, (requests.Timeout, requests.ConnectionError)):
return ErrorClassification.RETRY_BACKOFF, f'Network error: {type(exc).__name__}'
if isinstance(exc, psycopg2.errors.DeadlockDetected):
return ErrorClassification.RETRY_IMMEDIATELY, 'Deadlock detected — retry transaction'
if isinstance(exc, psycopg2.OperationalError):
msg = str(exc).lower()
if 'connection' in msg or 'timeout' in msg:
return ErrorClassification.RETRY_BACKOFF, f'DB connection error: {exc}'
return ErrorClassification.PERMANENT_FAILURE, f'DB operational error: {exc}'
if isinstance(exc, (ValueError, TypeError, KeyError)):
return ErrorClassification.ROW_LEVEL_FAILURE, f'Data error: {type(exc).__name__}: {exc}'
if isinstance(exc, (AttributeError, ImportError, SyntaxError)):
return ErrorClassification.PERMANENT_FAILURE, f'Code error (not data): {type(exc).__name__}: {exc}'
if isinstance(exc, MemoryError):
return ErrorClassification.PERMANENT_FAILURE, 'Out of memory — reduce batch size'
# Unknown errors — fail safe, treat as permanent until proven transient
return ErrorClassification.PERMANENT_FAILURE, f'Unknown error: {type(exc).__name__}: {exc}'>>> classify_error(requests.Timeout())
('retry_backoff', 'Network error: Timeout')
>>> classify_error(ValueError('negative_order_amount: -50.0'))
('row_level_failure', 'Data error: ValueError: negative_order_amount: -50.0')
>>> classify_error(None, response=<Response [401]>)
('permanent_failure', 'Authentication failed (401) — check credentials')classify_error for json.JSONDecodeError — is a response that fails to parse as JSON a row-level failure, a transient error, or a permanent one? Justify your answer before checking the Error Library at the end of this module.Retry Strategies — From Fixed Interval to Exponential Backoff With Jitter
Not all retries are equal. Retrying immediately, three times, makes things worse when the source system is under load — every retrying client resumes simultaneously, creating a thundering herd that overwhelms the already-struggling service. Exponential backoff spaces retries out; jitter desynchronises multiple parallel clients so they don’t all retry at the same moment.
A reusable retry decorator
import functools, logging, random, time
from typing import Callable, Type
log = logging.getLogger(__name__)
def retry_with_backoff(
max_attempts: int = 5, base_delay_s: float = 1.0, max_delay_s: float = 60.0,
jitter_factor: float = 0.25,
retryable_exceptions: tuple[Type[Exception], ...] = (Exception,),
non_retryable_exceptions: tuple[Type[Exception], ...] = (),
) -> Callable:
"""delay = min(base_delay * 2^attempt, max_delay) * (1 ± jitter_factor)"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except non_retryable_exceptions as exc:
log.error('Non-retryable error in %s (attempt %d/%d): %s',
func.__name__, attempt, max_attempts, str(exc))
raise
except retryable_exceptions as exc:
if attempt == max_attempts:
log.error('All %d attempts exhausted for %s: %s',
max_attempts, func.__name__, str(exc))
raise
raw_delay = min(base_delay_s * (2 ** (attempt - 1)), max_delay_s)
delay = max(0, raw_delay + raw_delay * jitter_factor * (2 * random.random() - 1))
log.warning('%s failed (attempt %d/%d): %s. Retrying in %.2fs',
func.__name__, attempt, max_attempts, str(exc), delay)
time.sleep(delay)
return wrapper
return decorator@retry_with_backoff(
max_attempts=5, base_delay_s=2.0,
retryable_exceptions=(requests.Timeout, requests.ConnectionError),
non_retryable_exceptions=(AuthenticationError, SchemaError),
)
def fetch_payments(from_ts: int, to_ts: int) -> dict:
response = requests.get('https://api.stripe.com/v1/payments',
params={'from': from_ts, 'to': to_ts},
auth=HTTPBasicAuth(KEY_ID, KEY_SECRET), timeout=30)
if response.status_code == 429:
wait = float(response.headers.get('Retry-After', 60))
raise RateLimitError(f'Rate limited — wait {wait}s')
response.raise_for_status()
return response.json()
@retry_with_backoff(max_attempts=3, base_delay_s=0.5,
retryable_exceptions=(psycopg2.errors.DeadlockDetected, psycopg2.OperationalError))
def write_batch_to_db(rows: list[dict], conn) -> int:
with conn:
psycopg2.extras.execute_values(cur, UPSERT_SQL, rows)
return len(rows)WARNING fetch_payments failed (attempt 1/5): Timeout. Retrying in 2.14s
WARNING fetch_payments failed (attempt 2/5): Timeout. Retrying in 3.87s
INFO fetch_payments succeeded on attempt 3Rate limit handling — the Retry-After pattern
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
def handle_rate_limit_response(response) -> float:
"""Retry-After can be an integer ("60") or an HTTP date string."""
retry_after = response.headers.get('Retry-After')
if not retry_after:
return 30.0 # no header — conservative default
try:
return float(retry_after)
except ValueError:
pass
try:
wait = (parsedate_to_datetime(retry_after) - datetime.now(timezone.utc)).total_seconds()
return max(0.0, wait)
except Exception:
return 30.0def api_call_with_rate_limit_handling(url: str, params: dict, auth, max_retries: int = 5) -> dict:
for attempt in range(1, max_retries + 1):
response = requests.get(url, params=params, auth=auth, timeout=30)
if response.status_code == 200:
return response.json()
elif response.status_code == 429:
wait = handle_rate_limit_response(response) * (1.0 + random.uniform(0, 0.1))
log.warning('Rate limited (attempt %d/%d) — waiting %.1fs', attempt, max_retries, wait)
if attempt < max_retries:
time.sleep(wait)
else:
response.raise_for_status()
elif response.status_code in (500, 502, 503, 504):
wait = min(2 ** attempt, 60) * (1 + random.uniform(-0.2, 0.2))
log.warning('Server error %d (attempt %d/%d) — waiting %.1fs',
response.status_code, attempt, max_retries, wait)
if attempt < max_retries:
time.sleep(wait)
else:
response.raise_for_status()
else:
response.raise_for_status() # 4xx other than 429 — do not retryWARNING Rate limited (attempt 1/5) — waiting 61.8s
INFO fetch_payments succeeded on attempt 2 after Retry-After delayJitter strategies — why randomisation matters
Without jitter, 100 pipeline instances failing at the same moment all retry at exactly the same delays — a wave of 100 requests at T+2s, then another wave at T+4s — making the recovering service’s job harder, not easier. Jitter spreads the same 100 retries evenly across the window.
def compute_backoff_delay(attempt: int, base_s: float = 1.0, max_s: float = 60.0, strategy: str = 'full_jitter') -> float:
cap = min(base_s * (2 ** attempt), max_s)
if strategy == 'fixed':
return cap # no randomisation — thundering herd risk
elif strategy == 'equal_jitter':
return cap / 2 + random.uniform(0, cap / 2) # moderate desynchronisation
elif strategy == 'full_jitter':
return random.uniform(0, cap) # AWS-recommended — max desynchronisation
elif strategy == 'decorrelated':
last = getattr(compute_backoff_delay, '_last', base_s)
delay = min(random.uniform(base_s, last * 3), max_s)
compute_backoff_delay._last = delay
return delay
return cap100 clients, all failing at T=0, retrying with base=1s, max=60s:
fixed (no jitter): all 100 retry at exactly T+2s, then all 100 at T+4s — a wave each time
full_jitter: retries land uniformly across [0,2s], then [0,4s] — ~50 req/s, not 100 at oncefull_jitter for multiple parallel pipeline instances hitting the same API. Use decorrelated for a single client retrying one sequential operation.Circuit Breaker — Stop Hammering a Failing System
Exponential backoff slows retries. A circuit breaker stops them entirely once a downstream system is clearly unavailable — like an electrical breaker that trips to cut power rather than let a circuit keep drawing current into a fault. Without one, a pipeline calling a failing API keeps trying, blocking threads and adding load to an already-struggling service.
Three states, one state machine
import threading, time
from enum import Enum
class CircuitState(Enum):
CLOSED = 'closed' # normal operation — requests flow through
OPEN = 'open' # tripped — requests fail immediately, no call made
HALF_OPEN = 'half_open' # testing recovery — one probe request allowed
class CircuitBreaker:
def __init__(self, name: str, failure_threshold: int = 5, success_threshold: int = 2,
window_s: float = 60.0, cooldown_s: float = 30.0):
self.name, self.failure_threshold, self.success_threshold = name, failure_threshold, success_threshold
self.window_s, self.cooldown_s = window_s, cooldown_s
self._state = CircuitState.CLOSED
self._failure_times: list[float] = []
self._half_open_success = 0
self._opened_at: float | None = None
self._lock = threading.Lock()
@property
def state(self) -> CircuitState:
with self._lock:
if self._state == CircuitState.OPEN:
if self._opened_at and time.monotonic() - self._opened_at >= self.cooldown_s:
self._state, self._half_open_success = CircuitState.HALF_OPEN, 0
log.info('Circuit %s: OPEN → HALF_OPEN (cooldown elapsed)', self.name)
return self._stateCalling through the breaker, and recording the outcome
def call(self, func, *args, **kwargs):
if self.state == CircuitState.OPEN:
raise CircuitOpenError(f'Circuit breaker {self.name} is OPEN — service unavailable')
try:
result = func(*args, **kwargs)
self._on_success()
return result
except Exception:
self._on_failure()
raise
def _on_success(self) -> None:
with self._lock:
if self._state == CircuitState.HALF_OPEN:
self._half_open_success += 1
if self._half_open_success >= self.success_threshold:
self._state, self._failure_times = CircuitState.CLOSED, []
log.info('Circuit %s: HALF_OPEN → CLOSED (service recovered)', self.name)
elif self._state == CircuitState.CLOSED:
now = time.monotonic()
self._failure_times = [t for t in self._failure_times if now - t < self.window_s]
def _on_failure(self) -> None:
with self._lock:
now = time.monotonic()
if self._state == CircuitState.HALF_OPEN:
self._state, self._opened_at = CircuitState.OPEN, now
log.warning('Circuit %s: HALF_OPEN → OPEN (probe failed)', self.name)
return
self._failure_times = [t for t in self._failure_times if now - t < self.window_s] + [now]
if len(self._failure_times) >= self.failure_threshold:
self._state, self._opened_at = CircuitState.OPEN, now
log.error('Circuit %s: CLOSED → OPEN (%d failures in %.0fs window)',
self.name, len(self._failure_times), self.window_s)
class CircuitOpenError(Exception):
passERROR Circuit stripe_api: CLOSED → OPEN (5 failures in 60s window)
# every call for the next 30s raises CircuitOpenError immediately — no network call made
INFO Circuit stripe_api: OPEN → HALF_OPEN (cooldown elapsed)
INFO Circuit stripe_api: HALF_OPEN → CLOSED (service recovered)Wiring it into the pipeline
stripe_circuit = CircuitBreaker(name='stripe_api', failure_threshold=5, cooldown_s=30.0)
def fetch_payments_safe(params: dict) -> dict:
try:
return stripe_circuit.call(requests.get, 'https://api.stripe.com/v1/payments',
params=params, auth=HTTPBasicAuth(KEY_ID, KEY_SECRET), timeout=30)
except CircuitOpenError:
log.warning('Stripe API circuit is OPEN — skipping payment fetch this run')
return {'items': [], 'cursor': None}Dead Letter Queue — Not a Trash Can, a Quarantine
A DLQ is where records go when they cannot be processed. The word “queue” is intentional — records are held with full context until a human investigates and decides whether to fix and reprocess, discard, or escalate. A DLQ with no context is useless; one nobody monitors accumulates forever; one with no reprocessing path is just delayed data loss.
The table — what to store
CREATE TABLE pipeline.dead_letter_queue (
id BIGSERIAL PRIMARY KEY,
pipeline_name VARCHAR(100) NOT NULL,
run_id UUID NOT NULL,
error_type VARCHAR(100) NOT NULL, -- 'validation', 'transform', 'schema'
error_message TEXT NOT NULL,
raw_record JSONB NOT NULL, -- the original record that failed
source_key VARCHAR(200), -- primary key from source, for lookup
rejected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
reprocess_count INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending | reprocessed | discarded | escalated
resolution_note TEXT,
CONSTRAINT chk_status CHECK (status IN ('pending','reprocessed','discarded','escalated'))
);
CREATE INDEX idx_dlq_pipeline_status ON pipeline.dead_letter_queue (pipeline_name, status, rejected_at);The writer
import json
from datetime import datetime, timezone
class DLQWriter:
def __init__(self, pipeline_name: str, run_id: str, dest_conn):
self.pipeline_name, self.run_id, self.dest_conn = pipeline_name, run_id, dest_conn
self._count = 0
def write(self, raw_record: dict, error_type: str, error_message: str, source_key: str | None = None) -> None:
safe_record = {}
for k, v in raw_record.items():
try:
json.dumps(v)
safe_record[k] = v
except (TypeError, ValueError):
safe_record[k] = str(v)
with self.dest_conn.cursor() as cur:
cur.execute("""
INSERT INTO pipeline.dead_letter_queue
(pipeline_name, run_id, error_type, error_message, raw_record, source_key)
VALUES (%s, %s, %s, %s, %s, %s)
""", (self.pipeline_name, self.run_id, error_type, error_message,
json.dumps(safe_record), source_key or str(raw_record.get('order_id', ''))))
self.dest_conn.commit()
self._count += 1
log.warning('DLQ: %s — %s (total DLQ count: %d)', error_type, error_message[:100], self._count)
@property
def count(self) -> int:
return self._countWARNING DLQ: non_numeric_delivery_fee — 'N/A' (total DLQ count: 1)
WARNING DLQ: non_numeric_delivery_fee — 'N/A' (total DLQ count: 2)
...
WARNING DLQ: non_numeric_delivery_fee — 'N/A' (total DLQ count: 5400)Monitoring queries
-- Daily DLQ summary per pipeline
SELECT pipeline_name, DATE(rejected_at) date, error_type,
COUNT(*) dlq_count, COUNT(*) FILTER (WHERE status = 'pending') pending_count
FROM pipeline.dead_letter_queue
WHERE rejected_at > NOW() - INTERVAL '7 days'
GROUP BY 1, 2, 3 ORDER BY 2 DESC, 4 DESC;
-- ALERT: if pending_count > 100 for any pipeline today
-- Most common rejection reasons today
SELECT error_type, error_message, COUNT(*) count
FROM pipeline.dead_letter_queue
WHERE rejected_at::DATE = CURRENT_DATE AND status = 'pending'
GROUP BY 1, 2 ORDER BY 3 DESC LIMIT 20;Reprocessing — closing the loop
Run manually after fixing the root cause that caused the rejections — for example, a vendor changed a status value, so VALID_STATUSES was updated, and now every quarantined record needs a second attempt.
def reprocess_dlq_records(pipeline_name: str, error_type: str, dest_conn, dry_run: bool = True) -> dict:
stats = {'attempted': 0, 'reprocessed': 0, 'failed_again': 0}
with dest_conn.cursor() as cur:
cur.execute("""
SELECT id, raw_record FROM pipeline.dead_letter_queue
WHERE pipeline_name = %s AND error_type = %s AND status = 'pending'
ORDER BY rejected_at ASC LIMIT 10000
""", (pipeline_name, error_type))
records = cur.fetchall()
log.info('Found %d DLQ records to reprocess (dry_run=%s)', len(records), dry_run)
for dlq_id, raw_record_json in records:
stats['attempted'] += 1
raw_record = json.loads(raw_record_json)
try:
result = validate_row(raw_record) # re-run with current (fixed) rules
if not result.is_valid:
if not dry_run:
mark_dlq(dlq_id, 'escalated', f'Still fails validation: {result.error}', dest_conn)
stats['failed_again'] += 1
continue
if not dry_run:
upsert_to_silver([project_to_dest_schema(enrich_order(result.row))], dest_conn)
mark_dlq(dlq_id, 'reprocessed', 'Successfully reprocessed after fix', dest_conn)
stats['reprocessed'] += 1
except Exception as exc:
log.error('Reprocessing failed for DLQ id %d: %s', dlq_id, str(exc))
stats['failed_again'] += 1
log.info('DLQ reprocessing complete: attempted=%d reprocessed=%d failed=%d',
stats['attempted'], stats['reprocessed'], stats['failed_again'])
return stats$ python dlq_reprocess.py --pipeline vendor_reconciliation --error-type validation --dry-run false
INFO Found 5400 DLQ records to reprocess (dry_run=False)
INFO DLQ reprocessing complete: attempted=5400 reprocessed=5400 failed=0Alerting — Signal, Not Noise
An alert that fires on every transient error creates fatigue — engineers start ignoring alerts because most resolve themselves. An alert that fires only on complete pipeline failure misses degraded states where the pipeline runs but produces wrong data. The art is choosing thresholds that surface real problems while suppressing noise.
The four-tier alerting model
What separates a useless alert from an actionable one
BAD: Subject: Pipeline Error
Body: An error occurred in the orders pipeline.
→ no context: what failed, what's the impact, where do I even look?
GOOD: Subject: [P1] orders_incremental pipeline FAILED — data stale since 06:00 UTC
Pipeline: orders_incremental (FreshCart Silver Layer)
Error: psycopg2.OperationalError: could not connect to server
Impact: Silver orders not updated since 06:00 UTC — SLA BREACHED
Progress: 47,000 / 48,234 rows processed (97% complete before failure)
Checkpoint: 05:59:47 UTC (saved at row 47,000)
DLQ count: 12 rows (0.025% — normal)
Links: Airflow run · Snowflake history · DLQ query
Next step: Airflow retries in 2 min (attempt 2 of 3); pages on-call if that fails toodef format_alert(run: 'PipelineRun', error: Exception) -> str:
return f"""
Pipeline: {run.pipeline_name}
Status: FAILED
Error: {type(error).__name__}: {error}
Impact: Data stale since {run.started_at.isoformat()} UTC
Run ID: {run.run_id}
Rows: {run.rows_written:,} written, {run.rows_rejected:,} rejected
DLQ: {run.dlq_count} records
Checkpoint: {load_watermark().isoformat()}
See: https://airflow.internal/dags/{run.pipeline_name}/
"""Error Handling at the Orchestration Layer — Airflow
The pipeline code handles row-level and request-level errors internally. The orchestration layer handles task-level and DAG-level failures — deciding when to retry, when to alert, and how failures propagate between dependent tasks.
Retry configuration and the SLA-miss callback
from datetime import timedelta
default_args = {
'retries': 3, 'retry_delay': timedelta(minutes=2),
'retry_exponential_backoff': True, # delays: 2m, 4m, 8m
'max_retry_delay': timedelta(minutes=30),
'execution_timeout': timedelta(minutes=15),
'email_on_failure': True, 'email_on_retry': False, # don't spam on expected retries
'email': ['data-team@freshcart.com'],
}
def sla_miss_callback(dag, task_list, blocking_task_list, slas, blocking_tis):
"""Fires when a task misses its SLA — a warning before it fully fails."""
missed_tasks = [sla.task_id for sla in slas]
send_slack_alert(channel='#data-alerts',
message=f':warning: SLA MISS: tasks {missed_tasks} in DAG {dag.dag_id} exceeded their SLA.',
urgency='warning')The failure callback — a rich, actionable Slack message
def task_failure_callback(context):
dag_run, task, ti, exc = context['dag_run'], context['task'], context['task_instance'], context.get('exception')
rows_written = ti.xcom_pull(key='rows_written') or 0
rows_rejected = ti.xcom_pull(key='rows_rejected') or 0
run_id = ti.xcom_pull(key='pipeline_run_id') or 'unknown'
message = f"""
*[P1] Pipeline FAILED — Manual Intervention Required*
*DAG:* {dag_run.dag_id} *Task:* {task.task_id} *Run:* {dag_run.run_id}
*Error:* {type(exc).__name__}: {exc}
*Progress before failure:*
Rows written: {rows_written:,}
Rows rejected: {rows_rejected:,}
*Actions:*
• Check Airflow: {ti.log_url}
• DLQ: SELECT * FROM pipeline.dead_letter_queue WHERE run_id='{run_id}'
"""
send_slack_alert(channel='#data-oncall', message=message, urgency='critical')The success callback — catching a degraded state that still “succeeded”
def task_success_callback(context):
ti = context['task_instance']
rows_written = ti.xcom_pull(key='rows_written') or 0
rows_rejected = ti.xcom_pull(key='rows_rejected') or 0
if rows_written + rows_rejected > 0:
rejection_rate = rows_rejected / (rows_written + rows_rejected)
if rejection_rate > 0.05:
send_slack_alert(channel='#data-quality',
message=f':warning: High DLQ rate in {ti.dag_id}: {rejection_rate:.1%} of rows rejected.',
urgency='warning')
# Wired onto the task:
ingest = PythonOperator(
task_id='ingest_orders', python_callable=run_pipeline,
on_failure_callback=task_failure_callback,
on_success_callback=task_success_callback,
sla=timedelta(minutes=10),
)# a run that "succeeds" but rejected 8% of rows still gets flagged:
WARNING High DLQ rate in orders_pipeline_incremental: 8.0% of rows rejected.
# the task shows green in the Airflow UI — this Slack message is the only
# signal that something's actually degradedFive Misconceptions About Error Handling
A Vendor File With 3% Bad Rows — Handling It Without Stopping the Pipeline
Every Monday, a logistics partner sends a CSV with 180,000 delivery records for the previous week. This week’s file has 5,400 rows where delivery_fee contains the string “N/A” instead of a decimal — a data entry issue on the vendor’s side. Without proper error handling, the pipeline would crash on the first invalid row and page the on-call engineer at 06:15 AM.
06:00:14 INFO Loaded 180,000 rows from ShipFast weekly report
06:00:18 WARNING non_numeric_delivery_fee: 'N/A' (source_key=SFD_001847) → DLQ (count: 1)
06:00:18 INFO [continues processing without stopping]
06:04:22 INFO Batch 1 complete: 5000 rows (47 rejected → DLQ)
...
06:18:44 INFO Batch 36 complete: 5000 rows (150 rejected → DLQ)
06:18:47 WARNING DLQ count: 5,400 rows (3.0%) — threshold 5.0% — within range
06:18:49 INFO Pipeline complete: 174,600/180,000 loaded, duration=18m37s, SUCCESS
# P3 alert sent (no P1 — below the 5% threshold):
📋 [P3] vendor_reconciliation: 5,400 rows in DLQ (3.0%)-- Data engineer reviews the DLQ:
SELECT error_message, COUNT(*) FROM pipeline.dead_letter_queue
WHERE run_id = 'def456' AND status = 'pending' GROUP BY 1;
-- non_numeric_delivery_fee: 'N/A' 5,400
# Root cause: vendor sends "N/A" for NULL delivery fees (cash-on-delivery orders)
# Fix: treat "N/A" as 0 in the delivery_fee parser, then reprocess:
$ python dlq_reprocess.py --pipeline vendor_reconciliation --error-type validation --dry-run falseDLQ reprocessing complete: attempted=5400 reprocessed=5400 failed=0
All 5,400 rows successfully loaded to silver.vendor_deliveriesRow-level validation errors went to the DLQ without stopping the pipeline. 97% of valid rows loaded on time. The DLQ count stayed below the P1 threshold, so no one was paged at 6 AM — the root cause was found and reprocessed inside ten minutes on Monday morning.
5 Interview Questions — With Complete Answers
Mistakes Beginners Make Constantly
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓Classify every error before deciding what to do: transient errors (network timeout, 5xx, 429, deadlock) should be retried with backoff. Permanent errors (401, 403, schema mismatch, disk full, bad credentials) should fail immediately and alert. Never retry a permanent error — it wastes time and delays the human intervention the error requires.
- ✓Exponential backoff formula: delay = min(base × 2^attempt, max_delay). Attempt 1: ~1s, attempt 2: ~2s, attempt 3: ~4s, attempt 4: ~8s. Always add jitter. Full jitter selects a random value between 0 and the computed cap, spreading retries from multiple parallel clients evenly across the window and preventing thundering herds.
- ✓Rate limit (429) responses require special handling: read the Retry-After header for the exact wait time instead of using exponential backoff. The API is telling you exactly how long to wait. Using a shorter generic backoff will result in another 429 immediately.
- ✓The circuit breaker has three states: closed (normal operation), open (all requests fail immediately — service gets time to recover), half-open (one probe request allowed to test recovery). Use circuit breakers for external third-party APIs where repeated timeouts would waste pipeline execution time and add load to a failing service.
- ✓A Dead Letter Queue is a quarantine, not a trash can. Store the complete raw record, the error type, the error message, the run ID, and the source key. Monitor pending DLQ counts. Alert at 5% rejection rate. Build a reprocessing job that can retry quarantined records after fixing the root cause.
- ✓The DLQ rejection rate threshold determines alert urgency. Below 1%: normal DLQ activity, log only. 1–5%: P3 warning, investigate next business day. Above 5%: P1 alert, investigate immediately. Above 20%: abort the pipeline — the batch has a systemic problem.
- ✓Handle errors at the right level. Row-level data errors (ValueError, invalid field) go to DLQ — catch them per row, continue processing. Infrastructure errors (connection timeout, 5xx) propagate up to the batch level for retry. High DLQ rate triggers pipeline abort rather than loading corrupted data.
- ✓Alert quality is as important as alert quantity. A good alert contains: pipeline name and run ID, error message, data impact (how stale is the data), rows processed before failure, DLQ count, checkpoint position, diagnostic links to Airflow logs and Snowflake query history, and automated recovery status.
- ✓Alert fatigue is a reliability risk. If engineers ignore alerts because 90% resolve automatically, real P1 incidents get missed. Only alert on conditions that require human action: all retries exhausted, SLA missed, permanent errors, high DLQ rate. Transient errors that resolve within the retry budget should be logged, not alerted.
- ✓The four-tier alerting model: P1 (page immediately) — SLA breach, authentication failure, schema mismatch, 5% DLQ rate. P2 (investigate within 1 hour) — all retries exhausted, DLQ growing across consecutive runs. P3 (investigate within 24 hours) — single run failed but recovered, DLQ has new records. No alert — log only — transient errors that resolved, successful runs.
What comes next
Module 28 covers pipeline orchestration — what a scheduler actually does, how DAGs model dependencies, what backfill means and why it is hard, and the design decisions that determine how maintainable an orchestration layer is.
Module 28 → Pipeline Orchestration — What a Scheduler DoesDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.