Week 3 introduced PII (L15), retry (L16), fallback (L17) — three guardrails in isolation. Production wraps all of them around every call. The pattern: nest them.
def safe_call(query):
clean = redact(query) # guardrail 1: PII
return with_retry(
lambda: with_fallback([primary, secondary])(clean),
max_attempts=3,
)Reading inside-out: the core is the LLM call, wrapped in fallback (try primary then secondary), wrapped in retry (each tier gets retried on rate-limit), and the input is redacted before any of it. Three layers of defense.
Order matters?
Yes. PII redaction is outermost on the input — you never want raw PII to reach any downstream tier, including the cache. Retry is inside fallback because retrying a primary that's permanently down wastes time; fall back to secondary first, retry within the secondary if needed.
Why decorators?
Pure functional composition. Each decorator wraps a function, returns a new function with the guardrail attached. You write the core call once, layer guardrails on top by chaining decorators. The same @with_retry @with_fallback @with_redaction stack ships across hundreds of call sites.
def with_pii_redaction(fn):
def wrapper(query, *args, **kwargs):
clean = redact(query)
return fn(clean, *args, **kwargs)
return wrapper
def with_retry(fn, max_attempts=3):
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return fn(*args, **kwargs)
except RateLimitError:
if attempt == max_attempts - 1:
raise
time.sleep(2 ** attempt)
return wrapper
def with_fallback(strategies):
def wrapper(*args, **kwargs):
for s in strategies:
try:
return s(*args, **kwargs)
except Exception:
continue
raise RuntimeError("all strategies failed")
return wrapper Input → [PII redact] → [retry] → [fallback] → [LLM] → Output
↓ ↓ ↓ ↓
OUTERMOST middle inner CORE
# god function — fragile, hard to test
def the_call_with_everything(query):
clean = redact(query)
for attempt in range(3):
for strategy in [primary, secondary]:
try:
return strategy(clean)
except RateLimitError:
time.sleep(2 ** attempt)
except Exception:
continue
raise RuntimeError("failed")Three concerns mixed; bug in one breaks the others. Composition keeps each guardrail independent: tested separately, swapped independently, reused across call sites.
Libraries like tenacity provide @retry decorators with exponential backoff, jitter, retry-on-specific-exception, and stop-after-N-attempts as kwargs. The composition is the same — tenacity just packages the implementation.
Week 3 introduced PII (L15), retry (L16), fallback (L17) — three guardrails in isolation. Production wraps all of them around every call. The pattern: nest them.
def safe_call(query):
clean = redact(query) # guardrail 1: PII
return with_retry(
lambda: with_fallback([primary, secondary])(clean),
max_attempts=3,
)Reading inside-out: the core is the LLM call, wrapped in fallback (try primary then secondary), wrapped in retry (each tier gets retried on rate-limit), and the input is redacted before any of it. Three layers of defense.
Order matters?
Yes. PII redaction is outermost on the input — you never want raw PII to reach any downstream tier, including the cache. Retry is inside fallback because retrying a primary that's permanently down wastes time; fall back to secondary first, retry within the secondary if needed.
Why decorators?
Pure functional composition. Each decorator wraps a function, returns a new function with the guardrail attached. You write the core call once, layer guardrails on top by chaining decorators. The same @with_retry @with_fallback @with_redaction stack ships across hundreds of call sites.
def with_pii_redaction(fn):
def wrapper(query, *args, **kwargs):
clean = redact(query)
return fn(clean, *args, **kwargs)
return wrapper
def with_retry(fn, max_attempts=3):
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return fn(*args, **kwargs)
except RateLimitError:
if attempt == max_attempts - 1:
raise
time.sleep(2 ** attempt)
return wrapper
def with_fallback(strategies):
def wrapper(*args, **kwargs):
for s in strategies:
try:
return s(*args, **kwargs)
except Exception:
continue
raise RuntimeError("all strategies failed")
return wrapper Input → [PII redact] → [retry] → [fallback] → [LLM] → Output
↓ ↓ ↓ ↓
OUTERMOST middle inner CORE
# god function — fragile, hard to test
def the_call_with_everything(query):
clean = redact(query)
for attempt in range(3):
for strategy in [primary, secondary]:
try:
return strategy(clean)
except RateLimitError:
time.sleep(2 ** attempt)
except Exception:
continue
raise RuntimeError("failed")Three concerns mixed; bug in one breaks the others. Composition keeps each guardrail independent: tested separately, swapped independently, reused across call sites.
Libraries like tenacity provide @retry decorators with exponential backoff, jitter, retry-on-specific-exception, and stop-after-N-attempts as kwargs. The composition is the same — tenacity just packages the implementation.
Create a free account to get started. Paid plans unlock all tracks.