Usage guide¶
- The 30-second version
- What a wrapped call returns
- Options
- Choosing which errors to catch
- The circuit breaker
- The disk cap
- Transient retries
- Poison items
- Redacting secrets
- Reruns and deduplication
- Retrying from Python
- Loops without a decorator
- Async
- Threads and processes
- Alerting
- The explicit object
- Recipes
- Things it deliberately does not do
The 30-second version¶
from quarantine import quarantine
@quarantine
def process(row):
price = float(row["price"])
save_to_db(row["id"], price)
for row in rows:
process(row)
A row that raises is written to .quarantine/ — the input, the traceback, and
the metadata — and the loop keeps going. At the end you get one line on stderr:
✓ 9,996 processed · ✗ 4 quarantined → .quarantine/ (run `quarantine retry` after fixing)
Then: quarantine list, fix the bug, quarantine retry.
What a wrapped call returns¶
| Situation | Return value |
|---|---|
| Success | whatever your function returned |
| Raised, and quarantined | QUARANTINED |
| Input already in quarantine from an earlier run | SKIPPED |
Both sentinels are falsy, and both are singletons that survive pickling
(so they behave under multiprocessing). Test them explicitly when it matters:
from quarantine import is_quarantined, is_skipped
result = process(row)
if is_quarantined(result):
continue # nothing to do; it is on disk with its traceback
If you need the value or nothing, filter with a truthiness check, or use
shield(), which never yields a sentinel.
Options¶
Every option is optional. These are the defaults:
@quarantine(
dir=".quarantine", # or $QUARANTINE_DIR
only=(Exception,), # what to quarantine
exclude=(), # what to let through anyway
halt_after=50, # consecutive-failure circuit breaker
max_items=10_000, # cap on the folder
retries=0, # transient retries before quarantining
backoff=0.0, # base delay between retries
backoff_factor=1.0, # grow the delay per retry (2.0 = exponential)
jitter=0.0, # random extra delay, spreads parallel workers out
dead_after=None, # blanket retries skip records failing N+ attempts
redact=(), # field names to scrub before saving
on_quarantine=None, # callback for each new record
on_retry_success=None, # callback when a retry recovers a record
on_retry_failure=None, # callback when a retry fails again
skip_known_bad=True, # skip inputs already quarantined
report=True, # print the summary at exit
verbose=False, # also print a line per item
)
def process(item): ...
Bad options fail immediately, at decoration time, rather than three hours into a run:
>>> @quarantine(halt_after=0)
ValueError: halt_after must be >= 1 or None, got 0
Two decorators with the same options share one instance — one set of counters, one summary line:
@quarantine(dir="bad")
def parse(row): ...
@quarantine(dir="bad")
def load(row): ...
parse.quarantine is load.quarantine # True
Choosing which errors to catch¶
only is an allowlist, exclude a denylist that wins over it:
@quarantine(only=(ValueError, KeyError)) # anything else still crashes
def process(row): ...
@quarantine(exclude=(MemoryError,)) # everything except this
def process(row): ...
Four things are never quarantined, whatever you pass:
KeyboardInterrupt, SystemExit, GeneratorExit, and quarantine's own
QuarantineError subclasses. Ctrl-C must interrupt, sys.exit() must exit,
and "halt, this looks systemic" must not be swallowed by the thing that said it.
The circuit breaker¶
If 50 items fail in a row, the data is probably fine and your database is not. Quarantining the remaining 9,950 items would produce noise, not information, so the run stops instead:
quarantine.errors.SystemicFailure: ✋ 50 consecutive failures - this looks
systemic, not bad data. Halting.
Last error: ConnectionError: db.internal:5432 refused
@quarantine(halt_after=100) # more patient
@quarantine(halt_after=None) # no breaker at all
The streak resets on every success, and the item that trips the breaker is
still saved before the exception is raised. SystemicFailure carries .count
and .last_error, and is chained from the original error, so a try/except
around your loop can tell the operator exactly what happened:
from quarantine import SystemicFailure
try:
for row in rows:
process(row)
except SystemicFailure as halt:
alert(f"pipeline halted after {halt.count} failures: {halt.last_error}")
raise
The disk cap¶
max_items (default 10,000) stops the folder eating a disk. When it is
reached, the failure is not dropped — QuarantineFull is raised instead,
chained from the original exception:
quarantine.errors.QuarantineFull: quarantine is full: 10000 items already in
.quarantine. Fix and `quarantine retry`, run `quarantine clear`, or raise
max_items.
Successful retries free capacity again. max_items=None removes the cap.
Transient retries¶
A wobbly network is not a bad item. retries re-runs a failing call before
quarantining it, so a blip never costs a record:
@quarantine(retries=2, backoff=0.5, backoff_factor=2.0, jitter=0.25)
def fetch(url): ...
| Option | What it does |
|---|---|
retries=2 |
Up to two extra attempts before the item is quarantined. |
backoff=0.5 |
Base delay in seconds before each extra attempt. |
backoff_factor=2.0 |
Grow the delay per attempt — here 0.5s then 1.0s. The default 1.0 keeps it fixed. |
jitter=0.25 |
Add up to this many random seconds to each delay, so a hundred parallel workers back off out of step instead of hammering a struggling service in lockstep. |
Only exceptions that would be quarantined are retried; anything only /
exclude lets propagate still propagates immediately, including
KeyboardInterrupt. Async functions get the same schedule with
await asyncio.sleep(...), so the event loop is never blocked.
Poison items¶
A record that keeps failing retry after retry eventually stops being worth
the time it costs. dead_after draws that line:
result = retry() # runs everything, every time
result = q.replace(dead_after=5).retry() # skips records with 5+ failed attempts
quarantine retry --dead-after 5
A dead record is reported in unretryable (reason dead: N failed attempts)
and left untouched — never deleted, never silently dropped. Retrying it by
explicit id always runs it: naming the record is the deliberate decision the
blanket retry refuses to make for you. The attempt count lives in meta.json,
so the threshold works across processes and across days.
Redacting secrets¶
Field names listed in redact are replaced with ***REDACTED*** before
anything is written:
@quarantine(redact=["password", "api_key", "*token*"])
def charge(payload, *, api_key): ...
- Matching is case-insensitive and supports globs:
*token*catchesAccessTokenandrefresh_token. - Walking is recursive through dicts, lists, tuples, sets, namedtuples, dataclasses and plain objects' attributes.
- Keyword-argument names are matched too, so
api_key=above is scrubbed even though the secret is not inside a container. - Your object is never mutated — everything is rebuilt as a copy.
- What was scrubbed is recorded in
meta.json("redacted": ["api_key"]), so a later reader knows the input is incomplete on purpose.
Two caveats worth knowing:
- Redaction happens before fingerprinting, so two inputs that differ only in a redacted field count as the same item for deduplication. That is a deliberate trade: no secret, not even a hash of one, reaches the disk.
- It scrubs fields. A secret pasted into an exception message is part of the traceback; do not put credentials in error strings.
Reruns and deduplication¶
Every call is fingerprinted (a hash of the redacted input plus the function name). On a rerun, an input already sitting in quarantine is skipped — not re-run, not re-recorded:
✓ 9,996 processed · ⏭ 4 skipped (already quarantined) → .quarantine/
This is what makes "just run it again" safe: the folder does not fill up with
duplicates, and your logs do not fill up with the same four errors. Fingerprints
are order-insensitive, so {"a": 1, "b": 2} and {"b": 2, "a": 1} are the same
item.
A record that a retry recovers is deleted, so the item is processed normally on
the next run. If you want the item re-attempted in place, pass
skip_known_bad=False.
Retrying from Python¶
The CLI's quarantine retry is a thin wrapper around this:
from quarantine import retry
result = retry() # everything
result = retry([2, 5]) # specific ids
result = retry(function="parse_row") # one function
result = retry(dry_run=True) # what would be retried
result.recovered # [1, 3] - deleted from the folder
result.still_failing # [2] - attempts incremented, traceback refreshed
result.unretryable # [(4, "reason")] - left untouched
Retries replay against the undecorated function, so a retry that fails again updates the existing record instead of creating a second one.
When quarantine cannot import the function a record came from — a closure, a
__main__ script, a renamed function — the record is reported in
unretryable and left alone. Point it at the right callable yourself:
retry(using=my_fixed_function)
For records produced by async def functions, use await aretry(...) (the
synchronous retry() will run them with asyncio.run when no loop is
running, and tell you to use aretry when one is).
Loops without a decorator¶
from quarantine import shield
for result in shield(items, using=process):
write(result)
shield() yields only the results that worked; failures are quarantined and
simply do not appear. It is lazy, and it takes the same options as the
decorator:
shield(rows, using=parse, dir="bad-rows", halt_after=None)
shield() needs a callable. A generator physically cannot catch an exception
raised in your for-loop body, and pretending otherwise would be a decorator
that protects nothing — so shield(items) raises TypeError rather than
quietly doing less than you think.
For the same reason, @quarantine refuses to wrap a generator function: its
body runs during iteration, not during the call. You get a clear TypeError
pointing you at shield() instead of silent non-protection.
Async¶
@quarantine detects async def and returns a coroutine function, so
inspect.iscoroutinefunction() still says yes and the decorator is invisible:
@quarantine(halt_after=None)
async def fetch(url):
async with session.get(url) as response:
return await response.json()
results = await asyncio.gather(*(fetch(url) for url in urls))
good = [r for r in results if not is_quarantined(r)]
ashield() is the async loop form, over sync or async iterables:
async for record in ashield(rows, using=load):
...
Threads and processes¶
Safe in both, and the design is the reason:
- Record ids are allocated by creating the directory; a collision means someone else won the race, so the next id is taken.
- Each record is assembled in a hidden
.tmp-*directory and renamed into place. The rename is the commit point, so a reader never sees a half-written record, andkill -9leaves an ignorable temp folder rather than corruption. index.jsonis written under a lock file, and is only ever a cache: if it is missing, stale or corrupt it is rebuilt from the record directories.- Counters are guarded by a lock, so
statsand the circuit breaker are accurate across threads.
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor() as pool:
list(pool.map(process, items)) # process is @quarantine-decorated
With multiprocessing, each worker keeps its own counters (so you get a
summary line per process), but they share the folder correctly. Run
quarantine reindex afterwards if you want one tidy index.
Alerting¶
def notify(record):
slack.post(f"{record.function} failed on {record.preview}: {record.summary}")
@quarantine(on_quarantine=notify)
def process(item): ...
The hook receives the Record after it is safely on disk. If your hook raises,
the exception is reported on stderr and the run continues — Slack being down is
not a reason to lose 9,996 items of work.
Retries have hooks of their own, so metrics can follow a record through its whole life, not just its birth:
@quarantine(
on_quarantine=lambda r: METRIC_QUARANTINED.labels(r.function, r.error_type).inc(),
on_retry_success=lambda r: METRIC_RECOVERED.labels(r.function).inc(),
on_retry_failure=lambda r: METRIC_STILL_FAILING.labels(r.function).inc(),
)
def process(item): ...
All three are reported-not-raised, so a broken metrics pipeline cannot break a retry. Ready-made wiring for Prometheus, Datadog and Sentry lives in observability.md.
For a per-item line without writing a hook, use verbose=True.
The explicit object¶
The decorator is sugar over a Quarantine object you can construct, inject and
inspect:
from quarantine import Quarantine
q = Quarantine("build/bad-rows", halt_after=10, report=False)
safe = q.wrap(process)
q.call(process, row)
q.stats # Stats(processed=…, quarantined=…, skipped=…, recovered=…)
q.records() # list[Record]
q.summary_line() # the one-liner, or None
q.replace(halt_after=None) # a copy with one setting changed
Useful in tests: point it at tmp_path, pass report=False, and assert on
q.records().
Recipes¶
Small in-library patterns. For whole-framework integrations — a pandas ETL job, a web scraper, an Airflow task, a FastAPI background worker — see framework recipes.
Fail the job if anything got quarantined
import sys
from quarantine import records
for row in rows:
process(row)
if records():
sys.exit(f"{len(records())} rows need attention: quarantine list")
Retry in the same run, after a pause (e.g. a flaky API)
import time
from quarantine import retry
for url in urls:
fetch(url)
time.sleep(60)
result = retry(using=fetch.__wrapped__)
print(f"{len(result.recovered)} recovered on the second pass")
Separate folders per stage, so a parse failure and a load failure never mix
@quarantine(dir=".quarantine/parse")
def parse(row): ...
@quarantine(dir=".quarantine/load")
def load(row): ...
Quarantine data, crash on bugs
@quarantine(only=(ValueError, KeyError, UnicodeDecodeError))
def parse(row): ...
A TypeError or AttributeError is usually your bug, not the row's; letting
those crash keeps them from hiding in a folder.
In a notebook, where report=True at exit is not much use:
from quarantine import quarantine, summary
@quarantine(verbose=True, report=False)
def process(item): ...
...
print(summary())
Things it deliberately does not do¶
- It does not retry automatically. No backoff, no jitter, no schedule. One failure, one record, one deliberate retry when you have fixed something.
- It does not swallow
KeyboardInterrupt,SystemExitor its own errors. - It is not a queue. No broker, no workers, no ordering guarantees beyond "ids increase". If you need those, you need Celery or Kafka, and they have dead-letter queues already.
- It does not touch your logging setup. Output is a single stderr line, and you can turn it off.