Framework recipes¶
Worked integrations for the places quarantine earns its keep. These are recipes rather than plugins, deliberately: an operator or extension per framework is a permanent maintenance treadmill against someone else's release cycle, while a page of working code delivers the value at none of the cost. Shorter, single-feature examples live in examples.md; the library idioms they build on are in the usage guide.
Each recipe was run against a real quarantine folder before it was written down.
A pandas ETL job¶
One malformed row should not cost a 500,000-row load. Clean row-wise through
shield(), which yields only the rows
that worked, and rebuild the frame from the survivors:
# etl.py
import pandas as pd
from quarantine import shield
def clean(row: dict) -> dict:
return {
"id": int(row["id"]),
"price": float(row["price"]), # raises on "N/A" - quarantined, loop continues
"qty": int(row["qty"]),
"total": float(row["price"]) * int(row["qty"]),
}
def run(csv_path: str) -> pd.DataFrame:
raw = pd.read_csv(csv_path, dtype=str, keep_default_na=False)
good = shield(
raw.to_dict("records"), using=clean, dir=".quarantine/etl", redact=["card_number"]
)
return pd.DataFrame(list(good))
if __name__ == "__main__":
frame = run("orders.csv")
frame.to_parquet("orders.parquet")
Two details are load-bearing:
dtype=str, keep_default_na=False— left to its defaults,read_csvsilently turns junk like"N/A"intoNaN, andfloat(nan)does not raise, so the bad row would sail through as NaNs instead of being quarantined. Reading raw strings makesclean()the one place where parsing happens — and failures surface.to_dict("records")instead ofdf.apply— a plain dict pickles cleanly and reads well ininput.txt, andapplywould abort the whole column on the first bad value: the opposite of what you want.
Afterwards, from the same directory:
quarantine list --dir .quarantine/etl
# fix clean() ...
quarantine retry --dir .quarantine/etl --import etl.py
--import etl.py is needed because clean lives in a script that ran as
__main__ (the CLI reference explains why). The recovered rows are
deleted from the folder; append them to the parquet on the next run, or re-run
the whole job — deduplication skips the
rows that are still bad instead of re-recording them.
A web scraper¶
Scrapers meet two very different failures: a wobbly network (retry it) and a genuinely broken page (quarantine it and move on). One decorator handles both, and the circuit breaker covers the third case — the whole site going down:
# scraper.py
import requests
from quarantine import quarantine
@quarantine(
dir=".quarantine/scrape",
retries=2, # a timeout is not a bad page: try again first
backoff=1.0,
backoff_factor=2.0, # 1s, then 2s
jitter=0.5, # parallel scrapers back off out of step
halt_after=20, # 20 failures in a row = the site is down, stop
)
def fetch(url: str) -> str:
response = requests.get(url, timeout=10)
response.raise_for_status()
return response.text
if __name__ == "__main__":
for url in load_urls():
page = fetch(url)
if page: # QUARANTINED is falsy, so failures skip the parse
parse(page)
A page that 404s twice lands in quarantine with the exact URL as its input; a page that timed out once and loaded on the second attempt costs nothing. When the site's bug is fixed, replay only the failures:
quarantine retry --dir .quarantine/scrape --import scraper.py
For a long-running crawl, add dead_after=5 so a permanently broken URL stops
being re-attempted by every blanket retry — see
poison items.
An Airflow task¶
Keep the load-bearing code in a plain module — importable by the Airflow
worker, by your laptop, and by quarantine retry alike — and keep the DAG
wiring thin:
# etl_tasks.py - no Airflow imports, so `quarantine retry --import` works anywhere
from quarantine import Quarantine
QDIR = "/shared/quarantine/nightly_load" # a volume workers and your laptop both see
def load_row(row: dict) -> None: ... # your real work
def load_partition(rows: list[dict]) -> int:
q = Quarantine(QDIR, halt_after=100, report=False)
load = q.wrap(load_row)
for row in rows:
load(row)
if len(q):
raise RuntimeError(
f"{len(q)} rows quarantined - inspect with: quarantine list --dir {QDIR}"
)
return q.stats.processed
# dags/nightly_load.py
import pendulum
from airflow.decorators import dag, task
from etl_tasks import load_partition
@dag(schedule="@daily", start_date=pendulum.datetime(2026, 1, 1))
def nightly_load():
@task(retries=2)
def load(rows: list[dict]) -> int:
return load_partition(rows)
load(rows=[]) # wire up your real source here
nightly_load()
How the pieces interact, because this is where quarantine and Airflow fit together unusually well:
- The task processes everything it can, then fails loudly. Every good row loads, every bad row is preserved with its traceback, and the red task is what makes a human look — nothing is silently dropped.
- Airflow's own task retries become cheap. On the re-run,
deduplication skips rows already in
quarantine instead of re-failing them. (Good rows do re-run — keep
load_rowidempotent, which an Airflow task should be anyway.) halt_after=100turns "the warehouse is down" into one fastSystemicFailureinstead of a partition's worth of identical records.report=Falsebecause the end-of-run summary line is for terminals; the task's return value and the raise carry the numbers into Airflow's UI.
Point QDIR at storage that outlives the worker pod. Inspect and replay from
anywhere that mounts it: quarantine list --dir /shared/quarantine/nightly_load.
A FastAPI background worker¶
A BackgroundTasks failure happens after the response is sent, so by
default it is a log line nobody reads. Quarantined, it is a record you can
inspect and a button you can click:
# app.py
from fastapi import BackgroundTasks, FastAPI
from quarantine import quarantine
app = FastAPI()
@quarantine(dir=".quarantine/receipts", retries=2, backoff=1.0, report=False)
def send_receipt(order: dict) -> None: ... # render and email the receipt
@app.post("/orders")
def create_order(order: dict, background: BackgroundTasks) -> dict:
background.add_task(send_receipt, order)
return {"status": "accepted"}
The endpoint keeps returning 202-shaped answers whatever the receipt does:
a transient SMTP wobble is retried, a genuinely bad order is quarantined with
its payload, and the request/response path never sees the failure.
report=False— a server never exits, so the exit summary would never print. Wireon_quarantine=to your metrics instead.- Multiple uvicorn workers share one folder safely — writes are atomic and ids cannot collide (see the on-disk format).
- Inspect and replay visually while the server runs:
quarantine ui --dir .quarantine/receipts, or from the CLI:quarantine retry --dir .quarantine/receipts --import app.py.
The common shape¶
All four recipes are the same three decisions:
- A folder per concern (
dir=), so a scrape failure and a load failure never mix. - Retries for transient noise, quarantine for real failures — the
split between
retries=and the record. - A replay path that works after the process is gone — an importable
function (or
--import file.py) and a folder that survives the run.