Non-blocking operations via Futures and dedicated Sessions#
Status: Accepted
Date: 2026-05-29
Deciders: saia-python maintainers
Context and Problem Statement#
Some SAIA operations are slow: audio transcription/translation, and ARCANA index
generation. Callers want to start them without blocking, and the client shares a
single requests.Session across all calls. Two questions follow:
How does a non-blocking call return its result?
How do background requests interact with the shared
Session?
The original wait=False paths spawned a daemon thread that ran the request on
the shared Session and discarded the return value. For voice that meant
the transcription — the entire point of the call — was lost; for both paths it
raced the shared Session (not documented thread-safe) against the caller’s
other requests.
Decision Drivers#
A non-blocking call must still be able to deliver its result.
requests.Sessionis not safe to use from multiple threads concurrently.daemonthreads can be killed at interpreter exit before completing.Keep the simple, blocking default (
wait=True) unchanged.
Considered Options#
Future + dedicated Session —
wait=Falsereturns aconcurrent.futures.Future; the worker runs on its ownSession.Lock around the shared Session — serialize all requests through a mutex.
No background thread — fire a short-timeout request and return.
Bare daemon thread (the original) — fire-and-forget, result discarded.
Decision Outcome#
Chosen option: Future + dedicated Session for VoiceService.transcribe() /
translate(): wait=False returns a Future[str] resolved on a worker thread
that uses its own Session. For ArcanaService.generate_index(wait=False) —
genuinely fire-and-forget, since the result is polled later via info() — the
background trigger now also runs on its own Session; the no-thread,
short-timeout option remains a reasonable alternative there.
Consequences#
Good — voice results are retrievable (
fut.result(),.done(), callbacks), and errors surface on.result()instead of vanishing.Good — background requests never share the client
Session, eliminating the data race with the caller’s concurrent calls.Trade-off — a dedicated
Sessionper background call forgoes connection keep-alive (one extra TLS handshake); negligible for these infrequent ops.Known limitation — workers are still
daemonthreads, so a process that exits beforefut.result()may abandon the request. Callers needing a guarantee should await theFuture.
Confirmation#
tests/test_voice.py asserts wait=False returns a resolvable Future, that
errors propagate through it, and that the shared Session is not used;
tests/test_arcana.py asserts generate_index(wait=False) posts on a fresh,
closed Session.
More Information#
The SSEStream wrapper (ADR-0002) is where streaming-response cleanup
(close() / connection release) lives. requests’ own guidance is to use one
Session per thread.