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:

  1. How does a non-blocking call return its result?

  2. 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.Session is not safe to use from multiple threads concurrently.

  • daemon threads can be killed at interpreter exit before completing.

  • Keep the simple, blocking default (wait=True) unchanged.

Considered Options#

  • Future + dedicated Sessionwait=False returns a concurrent.futures.Future; the worker runs on its own Session.

  • 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 Session per background call forgoes connection keep-alive (one extra TLS handshake); negligible for these infrequent ops.

  • Known limitation — workers are still daemon threads, so a process that exits before fut.result() may abandon the request. Callers needing a guarantee should await the Future.

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.