Thread pools fail in specific ways: a task that never returns, a provider that rate-limits, a dependency that
is down, a user who presses Ctrl-C, a job that silently drops 3% of its items. This file has a runnable
pattern for each. Exception design (custom hierarchies, retryable flags) is covered in
python-exception-handling-designer.
Contents
- What a timeout means for a thread
- Layered timeouts and deadlines
- Cooperative cancellation with threading.Event
- When you really need to kill work
- Retries with backoff and jitter
- Circuit breaker
- Progress reporting
- Partial results and errors without losing exceptions
- Error budgets: abort when too much fails
- Structured shutdown on Ctrl-C and SIGTERM
1. What a timeout means for a thread
Python threads cannot be killed or interrupted from outside. fut.result(timeout=5) raises TimeoutError
in the waiting thread; the task keeps running, keeps its worker busy, and its result is discarded when it
eventually finishes. fut.cancel() only works while the task is still queued.
import time
from concurrent.futures import ThreadPoolExecutor
finished = []
def slow_call() -> str:
time.sleep(0.3)
finished.append("slow")
return "late answer"
with ThreadPoolExecutor(max_workers=1) as ex:
fut = ex.submit(slow_call)
try:
fut.result(timeout=0.05)
except TimeoutError:
print("gave up waiting; cancel() ->", fut.cancel(), "running:", fut.running())
print("task still finished:", finished)
# gave up waiting; cancel() -> False running: True
# task still finished: ['slow']Consequences:
- Abandoned tasks still consume workers, rate-limit tokens and money. Under sustained timeouts the pool fills with zombies and throughput collapses.
- The real timeout must live inside the blocking call:
requests.get(url, timeout=(3.05, 30)),httpx.Client(timeout=httpx.Timeout(30, connect=5)),openai.OpenAI(timeout=60, max_retries=0), boto3Config(connect_timeout=5, read_timeout=60),sqlite3.connect(timeout=5),socket.settimeout. requestshas no default timeout: a call withouttimeout=can block forever on a dead connection.
2. Layered timeouts and deadlines
| Layer | Mechanism | Purpose |
|---|---|---|
| Socket / client | timeout= on every call | Bounds each attempt; the only layer that frees the thread |
| Attempt budget | retries x per-attempt timeout + backoff | Bounds a task |
| Waiting for one task | fut.result(timeout=...) | Bounds the caller's wait (request handlers) |
| Batch / job deadline | wait(fs, timeout=remaining) / as_completed(fs, timeout=...) | Bounds the whole fan-out |
Pass an absolute deadline down instead of re-computing relative timeouts at each layer:
import time
from concurrent.futures import ThreadPoolExecutor, wait
class Deadline:
def __init__(self, seconds: float) -> None:
self.at = time.monotonic() + seconds
def remaining(self) -> float:
return max(0.0, self.at - time.monotonic())
def expired(self) -> bool:
return self.remaining() == 0.0
def tool_call(name: str, deadline: Deadline) -> str:
time.sleep(0.5 if name == "slow_search" else 0.02) # real: client.call(..., timeout=deadline.remaining())
return name
deadline = Deadline(0.2)
with ThreadPoolExecutor(max_workers=4) as ex:
fs = {ex.submit(tool_call, n, deadline): n for n in ("calc", "sql", "slow_search")}
done, not_done = wait(fs, timeout=deadline.remaining())
print(sorted(fs[f] for f in done), [fs[f] for f in not_done])
# ['calc', 'sql'] ['slow_search']In an agent step, tools that missed the deadline become a "timed out" tool result for the model rather than an exception that kills the step.
3. Cooperative cancellation with threading.Event
Long tasks (paginating an API, uploading many parts, streaming a completion) should check a shared
threading.Event between steps and sleep with event.wait(delay) so a stop request interrupts the sleep.
import threading
import time
from concurrent.futures import ThreadPoolExecutor
class Stopped(Exception):
pass
def paginate(source: str, stop: threading.Event) -> int:
pages = 0
while pages < 100:
if stop.is_set():
raise Stopped(f"{source}: stopped after {pages} pages")
time.sleep(0.01) # fetch one page (with a client timeout)
pages += 1
if stop.wait(0.0): # back-off sleeps: stop.wait(delay), never time.sleep
raise Stopped(f"{source}: stopped after {pages} pages")
return pages
stop = threading.Event()
with ThreadPoolExecutor(max_workers=2) as ex:
futs = [ex.submit(paginate, s, stop) for s in ("crm", "wiki")]
time.sleep(0.1)
stop.set()
outcomes = []
for f in futs:
try:
outcomes.append(f.result())
except Stopped as exc:
outcomes.append(str(exc).split(":")[0])
print(outcomes) # ['crm', 'wiki']Pass the event explicitly (as an argument or via a small JobContext object), not as a global, so tests can
control it. For streaming LLM responses, check the event between chunks and close() the stream.
4. When you really need to kill work
| Need | Option |
|---|---|
| Hard limit on arbitrary code (untrusted eval code, a C call that ignores timeouts) | Run it in a subprocess (subprocess.run(..., timeout=) or a multiprocessing.Process you can kill()) |
| A stuck HTTP call | Set connect/read timeouts; for streaming, a total-time watchdog that closes the response from another thread |
| A thread stuck forever in a library | Nothing safe in-process. Make it a daemon only if losing its work is acceptable, and fix the timeout |
signal.alarm / signal.setitimer only interrupt the main thread, so they cannot time out pool tasks.
ctypes.pythonapi.PyThreadState_SetAsyncExc injects an exception at the next bytecode boundary, never inside
a blocking C call, and can corrupt state held by the interrupted code: do not use it.
5. Retries with backoff and jitter
Rules:
- Retry only transient failures: 429, 408, 5xx/overloaded, connection resets, read timeouts. Never 400, 401, 403, 404, context-length or content-policy errors.
- Exponential backoff with full jitter:
sleep = uniform(0, min(cap, base * 2**attempt)). Without jitter, 32 threads that hit a 429 together retry together. - Honour
Retry-After(seconds or HTTP date) when present:max(jittered, retry_after). - Cap attempts and total time; make retried writes idempotent (upsert by id, idempotency keys).
- Disable the SDK's own retries (
max_retries=0) if you retry yourself, or you multiply attempts. - Every attempt goes through the rate limiter.
import random
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Callable, Optional, Tuple, Type, TypeVar
R = TypeVar("R")
class TransientError(Exception):
def __init__(self, msg: str, retry_after: Optional[float] = None) -> None:
super().__init__(msg)
self.retry_after = retry_after
def retry(fn: Callable[[], R], *, attempts: int = 5, base: float = 0.05, cap: float = 2.0,
retry_on: Tuple[Type[BaseException], ...] = (TransientError, TimeoutError, ConnectionError),
stop: Optional[threading.Event] = None) -> R:
for attempt in range(attempts):
try:
return fn()
except retry_on as exc:
if attempt == attempts - 1:
raise
delay = random.uniform(0, min(cap, base * 2 ** attempt))
delay = max(delay, getattr(exc, "retry_after", None) or 0.0)
if stop is not None and stop.wait(delay):
raise
if stop is None:
time.sleep(delay)
raise AssertionError("unreachable")
calls = {"n": 0}
lock = threading.Lock()
def flaky() -> str:
with lock:
calls["n"] += 1
n = calls["n"]
if n < 3:
raise TransientError("429", retry_after=0.01)
return "ok"
with ThreadPoolExecutor(max_workers=1) as ex:
print(ex.submit(retry, flaky).result(), calls["n"]) # ok 3Retry inside the task (as above) rather than resubmitting from the caller: the worker keeps its context,
the caller's bookkeeping stays simple, and the attempt count is local. The retrying recipe adds an
on_retry hook for metrics.
6. Circuit breaker
When a dependency is down, 32 threads each burning 5 attempts with backoff is wasted time and quota. A breaker opens after N consecutive failures, fails fast for a cool-down, then lets one trial call through.
import threading
import time
class CircuitOpen(Exception):
pass
class CircuitBreaker:
def __init__(self, failures: int = 5, reset_after: float = 30.0) -> None:
self.failures, self.reset_after = failures, reset_after
self._count = 0
self._opened_at: float = 0.0
self._trial = False
self._lock = threading.Lock()
def call(self, fn, *args, **kwargs):
with self._lock:
if self._count >= self.failures:
if time.monotonic() - self._opened_at < self.reset_after or self._trial:
raise CircuitOpen("dependency unavailable; failing fast")
self._trial = True # half-open: exactly one trial call
try:
result = fn(*args, **kwargs)
except Exception:
with self._lock:
self._count += 1
self._trial = False
if self._count >= self.failures:
self._opened_at = time.monotonic()
raise
with self._lock:
self._count, self._trial = 0, False
return result
breaker = CircuitBreaker(failures=3, reset_after=0.1)
def down() -> None:
raise ConnectionError("vector db unreachable")
outcomes = []
for _ in range(5):
try:
breaker.call(down)
except (ConnectionError, CircuitOpen) as exc:
outcomes.append(type(exc).__name__)
time.sleep(0.12)
try:
breaker.call(lambda: "recovered")
outcomes.append("closed")
except CircuitOpen:
outcomes.append("still open")
print(outcomes)
# ['ConnectionError', 'ConnectionError', 'ConnectionError', 'CircuitOpen', 'CircuitOpen', 'closed']Put the breaker outside the retry loop (a retry should not retry CircuitOpen), one breaker per dependency.
7. Progress reporting
as_completed gives a natural progress loop. Use tqdm when installed, a periodic log line otherwise (CI logs
and containers do not render carriage returns well).
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
try:
from tqdm import tqdm
except ImportError:
tqdm = None
def grade(i: int) -> bool:
time.sleep(0.002)
return i % 9 != 0
items = list(range(200))
ok = failed = 0
with ThreadPoolExecutor(max_workers=8) as ex:
futures = [ex.submit(grade, i) for i in items]
bar = tqdm(total=len(futures), unit="item") if tqdm else None
for n, fut in enumerate(as_completed(futures), start=1):
if fut.result():
ok += 1
else:
failed += 1
if bar:
bar.update(1)
bar.set_postfix(ok=ok, failed=failed)
elif n % 100 == 0:
print(f"{n}/{len(futures)} ok={ok} failed={failed}", flush=True)
if bar:
bar.close()
# 100/200 ok=... failed=... (counts depend on completion order)
# 200/200 ok=177 failed=23tqdm updates from the consuming thread (as here) are safe; updating one bar from many worker threads works
(tqdm uses a lock) but costs contention, so prefer the consumer loop. Progress callbacks via
add_done_callback run in worker threads: guard shared counters with a lock.
8. Partial results and errors without losing exceptions
Collect a record per item with either a value or an error; decide at the end what failure means.
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Optional
@dataclass
class Outcome:
key: str
value: Optional[int] = None
error: Optional[BaseException] = None
def parse(doc: str) -> int:
if doc.endswith(".bad"):
raise ValueError(f"cannot parse {doc}")
return len(doc)
docs = ["a.pdf", "b.bad", "c.pdf", "d.bad"]
outcomes = []
with ThreadPoolExecutor(max_workers=4) as ex:
futures = {ex.submit(parse, d): d for d in docs}
for fut in as_completed(futures):
try:
outcomes.append(Outcome(futures[fut], value=fut.result()))
except Exception as exc:
outcomes.append(Outcome(futures[fut], error=exc))
errors = [o.error for o in outcomes if o.error]
print(sum(o.error is None for o in outcomes), "ok;", len(errors), "failed") # 2 ok; 2 failed
if errors and sys.version_info >= (3, 11):
try:
raise ExceptionGroup("2 documents failed", errors) # one raise carries every traceback
except ExceptionGroup as group: # callers can also use except* (3.11)
print(len(group.exceptions), "errors grouped") # 2 errors grouped- Log each failure once with
exc_info(log.warning("...", exc_info=exc)keeps the worker traceback). - Write successes as they arrive (JSONL), so a crash or Ctrl-C keeps finished work; record failures to a separate file for a re-run.
ExceptionGroup(3.11) is a clean way to raise everything at the end of a batch; callers can split it withexcept* ValueError:. On 3.9/3.10 raise a custom aggregate exception that carries the list.
9. Error budgets: abort when too much fails
If 40% of calls fail, something systemic is wrong (bad key, wrong model name, outage). Stop early instead of burning the whole dataset:
import threading
import time
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
def call(i: int) -> int:
time.sleep(0.002)
if i >= 20:
raise PermissionError("401 invalid API key") # systemic failure from item 20 on
return i
stop = threading.Event()
ok, failed, max_fail_ratio, min_samples = 0, 0, 0.2, 20
with ThreadPoolExecutor(max_workers=4) as ex:
pending = {ex.submit(call, i) for i in range(1000)}
while pending and not stop.is_set():
done, pending = wait(pending, return_when=FIRST_COMPLETED)
for f in done:
if f.exception() is None:
ok += 1
else:
failed += 1
total = ok + failed
if total >= min_samples and failed / total > max_fail_ratio:
stop.set()
ex.shutdown(wait=True, cancel_futures=True)
print(f"aborted={stop.is_set()} ok={ok} failed<100={failed < 100}")
# aborted=True ok=20 failed<100=True10. Structured shutdown on Ctrl-C and SIGTERM
What goes wrong by default: the main thread sits in fut.result() or in the with block's exit, Ctrl-C
raises KeyboardInterrupt, the with exit calls shutdown(wait=True), and every queued task runs anyway.
The process "ignores" Ctrl-C for minutes and keeps spending API quota.
The fix, in order:
- Wait in short slices:
wait(pending, timeout=0.25, return_when=FIRST_COMPLETED)(on Linux a blockingresult()is interruptible too, but slices also make the loop responsive to a stop flag). - On
KeyboardInterrupt:stop.set()so running tasks exit at their next check. ex.shutdown(wait=True, cancel_futures=True)(3.9+): queued tasks are cancelled, running ones finish.- Record what completed; write partial output; exit with status 130 (128 + SIGINT).
- Containers send SIGTERM: convert it to the same path with a signal handler that raises in the main thread.
import signal
import threading
import time
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
class Shutdown(Exception):
pass
def on_sigterm(signum, frame): # runs in the main thread
raise Shutdown(f"signal {signum}")
signal.signal(signal.SIGTERM, on_sigterm)
stop = threading.Event()
def work(i: int, stop: threading.Event) -> int:
for _ in range(4):
if stop.is_set():
return -1
time.sleep(0.01)
return i
completed = []
try:
with ThreadPoolExecutor(max_workers=4) as ex:
pending = {ex.submit(work, i, stop) for i in range(200)}
threading.Timer(0.2, signal.raise_signal, args=(signal.SIGTERM,)).start() # demo: SIGTERM later
try:
while pending:
done, pending = wait(pending, timeout=0.25, return_when=FIRST_COMPLETED)
completed += [f.result() for f in done]
except (KeyboardInterrupt, Shutdown):
stop.set()
ex.shutdown(wait=True, cancel_futures=True)
raise
except (KeyboardInterrupt, Shutdown) as exc:
print(f"stopped by {type(exc).__name__}: {len(completed) < 200} completed early, exit code 130")
# stopped by Shutdown: True completed early, exit code 130signal.raise_signal from the timer thread delivers SIGTERM to the process; Python runs the handler in the
main thread at the next bytecode boundary, which the 0.25 s wait slices guarantee. In a real CLI end with
sys.exit(130). The complete pattern with a report is scripts/executor_tool.py scaffold --recipe graceful-shutdown. A second Ctrl-C during cleanup raises another KeyboardInterrupt out of shutdown; if
you want "press twice to force quit", catch it there and call os._exit(130) (no cleanup runs).
Windows note: Ctrl-C cannot interrupt a thread blocked in some C calls; waiting in short slices is what makes the pattern portable.
python-async-deadlock-debugger
Diagnose and fix hangs, deadlocks, livelocks and starvation in asyncio, threaded and multiprocessing Python: classify CPU spin vs blocked IO vs deadlock vs lost wakeup vs event loop blocked by sync code, collect evidence with faulthandler, py-spy dump, gdb py-bt, asyncio.all_tasks, python -m asyncio ps/pstree, debug mode and sys._current_frames, then fix lock-order cycles, non-reentrant asyncio.Lock re-acquisition, run_coroutine_threadsafe().result() on the loop thread, Queue.join without task_done, Condition lost wakeups, semaphore leaks, futures nobody resolves, nested event loops, thread pool exhaustion, multiprocessing Queue/fork deadlocks and shutdown hangs. Ships a static hang scanner and a watchdog that dumps every thread and asyncio task.
python-asyncio-pattern-advisor
Recommend, implement and review Python asyncio patterns: async vs threads vs processes (IO vs CPU-bound), coroutines vs tasks vs futures, TaskGroup, gather and return_exceptions, wait and as_completed, Semaphore and Queue worker pools with backpressure, asyncio.timeout vs wait_for, cancellation, shield and cleanup, token-bucket RPM/TPM rate limits for LLM APIs, retries with backoff and jitter, async generators and SSE token streaming, to_thread/run_in_executor, background task references, eager tasks, asyncio.run/Runner, uvloop, Jupyter, FastAPI async vs def routes, httpx.AsyncClient lifespan, async DB/vector-store clients, debug mode and pytest-asyncio.
python-beginner-concept-tutor
Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.
python-comprehension-refactorer
Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.
python-context-manager-writer
Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.
python-dataclass-converter
Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.