async-sonic
A zero-dependency, fully typed asyncio client for the Sonic search index, with connection pooling and pipelining.
LLM-friendly reference: llms.txt.
Table of contents
- Why async-sonic
- Installation
- Quickstart
- API reference
- Concurrency and performance
- Error handling
- Escaping and limits
- Compatibility and limitations
- Development
- Contributing
- License
Why async-sonic
Sonic is a fast, lightweight search index that speaks a plain-text protocol
(PROTOCOL.md).
The asyncio client that existed on PyPI (asonic, last released in 2020) does not work, and
the other clients are synchronous. async-sonic implements the protocol from its specification
on top of asyncio.open_connection and nothing else.
- One class:
Sonic(host, port, password)used asasync with, with flat, obviously named methods (push,query,suggest, ...). Channels, handshake and pool are hidden. - Fast: a lazy per-channel connection pool plus pipelining (one background reader per connection). See the numbers.
- Zero runtime dependencies, Python 3.14+, strict typing,
py.typed. - Honest errors: no hidden retries; every exception message says what happened and what to do.
- LLM-friendly: short docstrings with a one-line example on every public method, plus
llms.txt.
Installation
uv add async-sonic # or: pip install async-sonic
The package is not on PyPI yet; until then install from Git:
uv add git+https://github.com/cr0hn/async-sonic
# or: pip install git+https://github.com/cr0hn/async-sonic
Quickstart
import asyncio
from async_sonic import Sonic
async def main() -> None:
async with Sonic("localhost", 1491, "SecretPassword") as sonic:
await sonic.push("videos", "catalog", "video:1", "cats and dogs are funny", lang="eng")
await sonic.push("videos", "catalog", "video:2", "stray dogs", lang="eng")
print(await sonic.query("videos", "catalog", "cats", lang="eng"))
await sonic.trigger(
"consolidate"
) # SUGGEST reads a graph that is only updated on consolidate
print(await sonic.suggest("videos", "catalog", "fun"))
await sonic.flush_collection("videos")
asyncio.run(main())
Output:
['video:1']
['funny']
This block is executed as a test against a real Sonic
(tests/test_integration.py::test_readme_quickstart_runs), so it cannot drift from the code.
To try it, start Sonic (see Development) and use its host, port and password.
API reference
Sonic opens nothing on construction or on async with entry: connections are opened lazily on
the first command, one pool per channel.
Sonic(host="localhost", port=1491, password="", *,
pool_size=4, max_in_flight=None, timeout=10.0, connect_timeout=5.0)
| Method | Sonic command | Channel | Returns |
|---|---|---|---|
query(collection, bucket, terms, *, limit, offset, lang) |
QUERY |
search | list[str] object ids, best first |
suggest(collection, bucket, word, *, limit) |
SUGGEST |
search | list[str] words |
list_words(collection, bucket, *, limit, offset) |
LIST |
search | list[str] words |
push(collection, bucket, object, text, *, lang) |
PUSH |
ingest | None |
pop(collection, bucket, object, text) |
POP |
ingest | int |
count(collection, bucket=None, object=None) |
COUNT |
ingest | int |
flush_collection(collection) |
FLUSHC |
ingest | int |
flush_bucket(collection, bucket) |
FLUSHB |
ingest | int |
flush_object(collection, bucket, object) |
FLUSHO |
ingest | int |
trigger(action=None, data=None) |
TRIGGER |
control | str |
info() |
INFO |
control | dict[str, int] |
ping() |
PING |
control | None |
help(manual=None) |
HELP |
control | str |
close() (or leaving the async with) sends QUIT on every connection. quote(text) is public.
lang is an ISO 639-3 code ("eng", "spa") or "none". If omitted, Sonic guesses the
language of the text, and may guess differently at index and query time: pass it on both sides.
Deliberate differences from the protocol:
list_words: Sonic'sLISTenumerates words of the index, not objects.countusesCOUNTrather thanCOUNTC/COUNTB/COUNTO(see Compatibility).
Concurrency and performance
A Sonic channel is a TCP connection in one mode (search, ingest or control).
- Pool: up to
pool_sizeconnections per channel, opened when needed (an idle connection is reused before a new one is opened). A command goes to the least loaded connection. - Pipelining: each connection writes commands without waiting for replies, and a background
reader task hands the replies to futures.
PROTOCOL.mdallows this: immediate replies (OK,RESULT,PONG,PENDING <id>,ERR) arrive in order (a FIFO of futures), while theEVENTlines ofQUERY/SUGGEST/LISTmay arrive out of order and are matched by the id of theirPENDING.max_in_flightcaps simultaneous commands per connection (None= unlimited,1= no pipelining). - No retries, no magic reconnection. If a connection drops, its in-flight commands fail with
SonicConnectionError; the next command opens a fresh connection. What to retry, and when, is your decision (aPUSHis idempotent if youflush_objectfirst; aPOPis not). - Per-command timeout (
timeout) raisesSonicTimeout, but the connection stays usable: the late reply is discarded when it arrives and nothing gets out of sync.
Measured numbers
benchmarks/bench.py: 2000 operations per row, Sonic v1.9.1 in Docker Desktop (4 CPUs) on an
Apple M2 Max, over localhost. It is a single run and numbers vary from run to run: trust the
orders of magnitude, not the decimals.
| Scenario | QUERY ops/s | PUSH ops/s |
|---|---|---|
| 1 connection, sequential | 2205 | 3061 |
| pool of 8, concurrent, no pipelining | 10549 | 7559 |
| 1 connection, pipelining | 13464 | 12795 |
| pool of 8 + pipelining | 14843 | 11104 |
Pipelining is the big lever: 4x to 6x over sequential. On top of it the pool did not help in
this run (an earlier run showed +40% for queries and nothing for writes), so with pipelining one
connection is often enough; the pool mostly matters when max_in_flight is capped. Over a real
network with latency the gap to the sequential case is larger, because a round trip is paid once
per burst instead of once per command; that was not measured.
Error handling
Every exception inherits from SonicError; messages say what happened and what to do.
| Exception | When |
|---|---|
SonicConnectionError |
could not connect, or the connection dropped, was closed or received ENDED |
SonicTimeout (subclass of the previous one) |
connect_timeout or timeout expired |
SonicServerError |
Sonic answered ERR ... (.code, .line); also a wrong password (authentication_failed) |
SonicProtocolError |
Sonic said something outside PROTOCOL.md; the connection is closed |
ValueError (builtin) |
invalid argument, or a command that does not fit in the buffer; nothing is sent |
from async_sonic import Sonic, SonicServerError, SonicTimeout
async with Sonic(password="SecretPassword") as sonic:
try:
await sonic.query("videos", "catalog", "cats", limit=0)
except SonicServerError as exc:
print(exc.code) # policy_reject
except SonicTimeout:
... # Sonic did not answer in time; the connection is still usable
Escaping and limits
quote(text) wraps text in quotes: " becomes \", \ becomes \\ (so a trailing backslash
cannot swallow the closing quote) and newlines (\n, \r) become a space, because a raw newline
would cut the command in two; Sonic tokenizes on whitespace, so no word is lost. Unicode goes
through as UTF-8. collection, bucket, object, action... cannot be empty or contain
whitespace, quotes or control characters (ValueError).
Buffer. STARTED ... buffer(N) (20000 by default) is the limit of a whole command line,
newline included. PROTOCOL.md asks clients to split: push does it for you, emitting several
PUSH commands for the same object, always cutting between words. query, suggest and
pop are not split (splitting would change their meaning) and raise ValueError, as does a
single word longer than the buffer.
Compatibility and limitations
- Verified: Sonic v1.9.1 (official image
valeriansaliou/sonic:v1.9.1) on Docker Desktop, macOS arm64, Python 3.14. Every command in the table above; text with quotes, backslashes, newlines, accents, CJK and emoji; a 48 KB text (split by the buffer); realERRreplies; a wrong password; and 200 concurrent queries over the pool with pipelining. - Deviations from
PROTOCOL.mdfound on v1.9.1:COUNTC,COUNTBandCOUNTOanswerERR unknown_command;COUNTis used instead, andcount(collection, bucket)returns the number of distinct words, not of objects (measured: 2 objects, 4 words,countreturned 4).SUGGESTandLISTonly see new words aftertrigger("consolidate").- A wrong password is answered with
ENDED authentication_failed(surfaced asSonicServerError). - Replies end in
\r\n.
- Not verified: other Sonic versions (the
COUNTC/B/Ocommands may exist in later ones);TRIGGER backupandrestore(they are sent, never tested against real data); Linux and Windows (CI covers Linux); sustained load or a server with many clients; network latency. - No TLS: Sonic Channel is plain TCP. Do not expose it to an untrusted network.
- Searching without accents (
cancionforcanción) depends on the server configuration (diacritic_folding_enabled), not on this client; the testsonic.cfgdoes not enable it.
Development
uv sync
uv run ruff check . && uv run ruff format --check .
uv run pyright
uv run pytest # everything
uv run pytest tests/test_unit.py # unit tests only, no docker needed
uv run python benchmarks/bench.py # benchmark against a real Sonic
tests/test_unit.pyruns against a fake asyncio server (tests/fake_sonic.py) that speaks the real protocol: handshake, out-of-orderPENDING/EVENT,ERR, abrupt close,ENDED, slow replies (timeouts and late replies), buffer splitting, pool and pipelining with latency.tests/test_integration.pyruns against a real Sonic. Ifdockeris available, the fixture startsvaleriansaliou/sonic:v1.9.1withtests/sonic.cfg(passwordSecretPassword) on a free port. Without docker these tests are skipped with an explicit reason (SKIPPED: docker is not available...): always check theskippedcount, because a green run with every integration test skipped does not prove the real wiring. CI fails if any test is skipped.
Contributing
Issues and pull requests are welcome; see CONTRIBUTING.md. Please follow the Code of Conduct. Security reports: SECURITY.md.
License
MIT (c) 2026 Daniel Alfocea.
Metadata
Release files for async-sonic 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| async_sonic-0.1.0.tar.gz | 33.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| async_sonic-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 47.0 kB
Release files / async_sonic-0.1.0.tar.gz
| Download URL | async_sonic-0.1.0.tar.gz |
|---|---|
| Size | 33.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0e4475096aa6fbe1a76cd469d0a0e4fbd73ab15897decfbf7c6f8711f97e2ab3
|
|
BLAKE2b-256 checksum How to use checksums |
cb2b82988abe8536786ae8c789651cea9e15db40d6ad58f106857bfdac97a130
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.12 {"installer":{"name":"uv","version":"0.11.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / async_sonic-0.1.0-py3-none-any.whl
| Download URL | async_sonic-0.1.0-py3-none-any.whl |
|---|---|
| Size | 14.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9a99a9408ca6663a30e243bdd2618582547d395b00d2532679acdcf046daa9d6
|
|
BLAKE2b-256 checksum How to use checksums |
7a3bd145ce033dfad979067237c56a0ae32495c7cf80d830566887fc0962ff8c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.12 {"installer":{"name":"uv","version":"0.11.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|