Async client

qkdsec.client.aio.AsyncETSI014Client is the asyncio-native counterpart to the synchronous ETSI014Client. It uses httpx under the hood and has the same method surface — anything you can do with the sync client, you can do with await.

Install

pip install "qkdsec[async]"

The async extra adds httpx>=0.27. The sync client and async client can coexist; pick whichever fits the surrounding code.

Usage

Context-manager style

import asyncio
from qkdsec.client.aio import AsyncETSI014Client


async def main():
    async with AsyncETSI014Client(
        "https://kme.example.com",
        client_cert=("alice.crt", "alice.key"),
    ) as kme:
        status = await kme.status("sae-bob")
        keys = await kme.get_enc_keys("sae-bob", number=1, size=256)
        print(keys[0].key.hex())


asyncio.run(main())

The async with form is the recommended pattern — it ensures the underlying httpx client is closed cleanly even on exception paths.

Manual lifecycle

kme = AsyncETSI014Client(...)
try:
    await kme.status("sae-bob")
finally:
    await kme.aclose()

Re-using an httpx client

For advanced configuration (custom transport, mounts, connection pool tuning), build your own httpx.AsyncClient and pass it in:

import httpx
from qkdsec.client.aio import AsyncETSI014Client

http = httpx.AsyncClient(
    cert=("alice.crt", "alice.key"),
    verify="ca.crt",
    timeout=httpx.Timeout(10.0, connect=5.0),
    limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
)
async with AsyncETSI014Client("https://kme.example.com", client=http) as kme:
    await kme.status("sae-bob")

When you pass client=, AsyncETSI014Client does not own the lifecycle. Close it yourself.

Concurrency patterns

Parallel key fetches

async with AsyncETSI014Client(...) as kme:
    batches = await asyncio.gather(*[
        kme.get_enc_keys("sae-bob", number=10, size=256)
        for _ in range(5)
    ])
    all_keys = [k for batch in batches for k in batch]

Pipelined enc → dec

async with AsyncETSI014Client(...) as alice, \
           AsyncETSI014Client(...) as bob:
    keys = await alice.get_enc_keys("sae-bob", number=1, size=256)
    mirror = await bob.get_dec_keys("sae-bob", key_ids=[keys[0].key_id])
    assert mirror[0].key == keys[0].key

Background key replenishment

async def keep_pool_full(kme, pool, target=20):
    while True:
        if len(pool) < target:
            new = await kme.get_enc_keys("sae-bob", number=target - len(pool))
            pool.extend(new)
        await asyncio.sleep(1.0)

Error handling

The same exception hierarchy applies as in the sync client:

from qkdsec.client import KMEError, KMEHTTPError, KMENotFoundError

try:
    keys = await kme.get_dec_keys("sae-bob", key_ids=["missing"])
except KMENotFoundError:
    ...  # 404: key_ID not in the pending pool (already consumed?)
except KMEHTTPError as e:
    ...  # other 4xx/5xx; e.status_code and e.message available
except KMEError:
    ...  # transport-level issues

Spec coverage

Every parameter on the sync client is available on the async client with identical semantics — see the spec coverage guide for multicast and extensions.

keys = await kme.get_enc_keys(
    "sae-bob",
    number=1,
    size=256,
    additional_slave_sae_ids=["sae-charlie", "sae-dave"],
    extension_optional=[{"route_type": "primary"}],
)

When to use which

  • Sync is fine for scripts, the CLI, and codebases that are not already async. The doctor probe is sync internally for exactly this reason — it has no shared state with the host application.

  • Async is the right choice when the surrounding code is already async (FastAPI, aiohttp services, asyncio-based background workers) or when you need to fan out many concurrent KME requests.