Croniq Runner SDK for Python
Build job execution runners for Croniq in async Python. The SDK polls a Croniq server for work, dispatches your handlers, streams structured logs back, and reports completion — idiomatic asyncio + httpx + Pydantic v2.
Install
pip install croniq-runner
# Optional: OpenTelemetry tracing
pip install "croniq-runner[otel]"
Python 3.11+ required (asyncio.TaskGroup, tomllib).
Quick start
import asyncio
import os
from croniq_runner import Runner, RunnerOptions
async def hello(ctx):
ctx.logger.info("hello from %s (attempt %d)", ctx.job_key, ctx.attempt)
await ctx.log("emitting a structured event", fields={"customer": "acme"})
async def main():
runner = Runner(RunnerOptions(
server_url="http://localhost:4000",
api_key=os.environ["CRONIQ_API_KEY"],
capabilities=["billing"],
tags=["lang=python", "env=dev"],
max_inflight=5,
))
runner.add_handler("hello:world", hello)
await runner.run()
asyncio.run(main())
The key comes from the environment on purpose — matching the Go and
TypeScript samples. A literal api_key="croniq_..." in a snippet like this
gets copy-pasted into source control more often than not.
Stop the runner with Ctrl-C or by calling runner.request_drain() from
another coroutine — in-flight handlers get up to drain_timeout_ms to finish
before the loop returns.
Features
asyncio-first — every public coroutine returns awaitably; no sync surface.- Pydantic v2 DTOs mirroring
openapi.yamlsnake_case wire format. - Two-tier logging:
ctx.logger— standardlogging.Loggerscoped withexecution_id,job_key,runner_id,attempt.ctx.log_writer— streaming channel backed by a boundedasyncio.Queuewith batching (32 events / 200 ms / max 100 per POST), drained before the ack.
- Server-side cancellation —
PollResponse.cancelis honoured viactx.cancellation(anasyncio.Event). Handlers shouldawait ctx.cancellation.wait()between checkpoints, or just useawait asyncio.sleep(...)— the runner cancels the underlying task when the event fires. - Lease renewal — periodic
POST /v1/work/renewheartbeat for each in-flight execution. - Self-registration — pass
schedule="5m"toadd_handlerand the runner POSTs to/v1/jobs/registeron startup. - OpenTelemetry — opt-in via the
[otel]extra; spans wrap each handler invocation whenopentelemetry-apiis importable. Zero dependency otherwise.
Capabilities vs Tags
A common pitfall: don't put implementation details into capabilities. Capabilities drive server-side job routing (require/prefer in the Croniqfile). Tags are filter-only — they show up in the UI and operational views but don't influence routing.
| Good capability | Bad capability |
|---|---|
billing, reporting, gpu, sandboxed |
python, linux-x64, dotnet |
If your runner is Python-based, that belongs in tags (lang=python, platform=linux-x64), not capabilities — so a future Rust- or .NET-runner with the same business capabilities can take over without rewriting Croniqfile entries.
Handler API
A handler is any async def fn(ctx: ExecutionContext) -> None. The ctx
exposes:
| Attribute | Meaning |
|---|---|
execution_id |
Server-assigned execution identifier |
job_key |
E.g. "billing:invoice" |
attempt |
1-based attempt counter (incremented on retry) |
metadata |
Raw dict from the server (job-specific schema) |
timeout |
datetime.timedelta declared by the server |
runner_id, runner_tags |
This runner's identity |
cancellation |
asyncio.Event — fires on host shutdown or server-initiated cancel |
logger |
logging.Logger pre-scoped with execution identifiers |
log_writer |
Streaming LogWriter (created lazily on first access) |
Two ways to control the ack failure message:
from croniq_runner import HandlerError
async def my_handler(ctx):
if not data_available():
raise HandlerError("upstream feed unavailable") # ack.error = "upstream feed unavailable"
Any other exception's str(exc) is forwarded as the error message.
Configuration
| Option | Default | Meaning |
|---|---|---|
server_url |
http://localhost:4000 |
Croniq server base URL — HTTPS required off loopback, see below |
allow_insecure_http |
False |
Opt in to a cleartext http:// server_url on a non-loopback host |
runner_id |
resolved at start | Stable runner identifier — see resolution order in _identity.py |
api_key / bearer_token |
None |
Auth header (ApiKey preferred when both set) |
capabilities |
[] |
Capabilities advertised to the server |
tags |
[] |
Free-form key=value tags |
max_inflight |
5 |
Concurrent in-flight executions |
poll_timeout_ms |
35_000 |
Per-request long-poll timeout |
request_timeout_ms |
30_000 |
Whole-request bound on ack, renew, log events and job registration |
renew_interval_ms |
15_000 |
Lease-renewal heartbeat interval |
drain_timeout_ms |
30_000 |
Wait budget for handlers on shutdown |
poll_retry_delay_ms |
5_000 |
Back-off after a failed poll |
capacity_backoff_ms |
500 |
Idle delay at max_inflight |
max_consecutive_poll_conflicts |
3 |
Consecutive poll 409s before PollInstanceConflictError |
log_writer |
LogWriterOptions() |
Streaming-log tunables |
Transport security
The API key is attached to every request as an Authorization header. Over
http:// it travels in cleartext — and because httpx honours HTTP_PROXY by
default, it can traverse an intermediary in the clear too.
RunnerOptions and TriggerClientOptions therefore refuse a cleartext
server_url at construction time unless the host is loopback:
- accepted: any
https://URL, andhttp://onlocalhost,127.0.0.0/8or::1— so thehttp://localhost:4000quickstart default keeps working; - refused:
http://on any other host, with aValueErrornaming the URL and the opt-in.
If a deployment genuinely has no TLS terminator (a lab or staging box), opt in
explicitly — the SDK then starts, but logs one loud warning on the
croniq_runner.security logger:
RunnerOptions(
server_url="http://croniq.internal:4000",
api_key=os.environ["CRONIQ_API_KEY"],
allow_insecure_http=True, # the API key travels in cleartext
)
Streaming logs example
from croniq_runner import LogLevel
async def long_job(ctx):
async with ctx.log_writer as writer: # type: ignore[reportInvalidUsage]
async for line in slow_generator():
await writer.write(line, level=LogLevel.INFO)
You don't actually need async with — the runner drains the writer before
the ack regardless. Calling aclose() yourself just lets you control when
the drain happens (e.g. before a downstream API call that should see the
events first).
Triggering jobs on demand (producer)
The Runner above is the consumer side. To fire a job on demand — from a
web request, a message handler, anywhere — use TriggerClient (the producer).
It wraps POST /v1/trigger and is independent of the runner: a pure producer
never polls, and it carries its own credentials, because triggering needs
the jobs:trigger (or admin) scope that runner poll keys typically lack.
import asyncio
import os
from croniq_runner import TriggerClient, TriggerClientOptions
async def main():
async with TriggerClient(TriggerClientOptions(
server_url="http://localhost:4000",
# jobs:trigger (or admin) scope — not a runner key
api_key=os.environ["CRONIQ_TRIGGER_KEY"],
)) as client:
result = await client.trigger(
"billing:invoice",
metadata={"invoice_id": "inv_42"},
idempotency_key="evt-2026-07-14-001", # optional: dedup retries
)
print(result.execution_id, result.queued, result.deduplicated)
asyncio.run(main())
trigger() takes the same routing/metadata knobs as a scheduled fire:
| Argument | Meaning |
|---|---|
job_key |
Job to fire, e.g. "billing:invoice" (required) |
metadata |
Arbitrary JSON passed to the handler (merged over the job's DSL metadata) |
require |
Capabilities a runner must have |
prefer |
Capabilities used to prefer a runner when several are eligible |
timeout |
Execution timeout as a duration string ("30s", "5m"); server default when omitted |
idempotency_key |
Dedup key (≤ 200 chars); repeat triggers with the same (job_key, key) coalesce onto the existing execution |
…and returns a TriggerResult:
| Field | Meaning |
|---|---|
execution_id |
The created — or, on a dedup hit, the existing — execution |
queued |
Server work-queue depth after the trigger |
deduplicated |
True when coalesced onto an existing execution via idempotency_key; False on servers without idempotency support |
Unset optional arguments are omitted from the request body (never sent as
null). A non-2xx response raises httpx.HTTPStatusError — including the
per-job queue-overflow 429, so a producer batching triggers observes that
backpressure instead of piling work up unbounded.
Conformance suite
The Python binding for the language-agnostic conformance
suite lives under tests/conformance/. Every
case is one pytest parameter; run them with:
pip install -e ".[dev]"
pytest tests/conformance
The cases live at sdks/conformance/cases/*.yaml and are loaded by file —
adding a new YAML automatically adds a new test. The producer (trigger) cases at
sdks/conformance/cases-trigger/*.yaml are driven the same way and exercise
TriggerClient.
Development
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev,otel]"
ruff check .
mypy
pytest
Releasing
Releases run via .github/workflows/python-sdk-release.yml and upload to PyPI through Trusted Publishing (OIDC — no API token in the repo).
- Bump
version = "X.Y.Z"inpyproject.toml. - Add a
## [X.Y.Z]section toCHANGELOG.md. - Commit, push to
main. - Tag and push:
git tag python-sdk-vX.Y.Z git push origin python-sdk-vX.Y.Z
- The workflow builds, verifies the tag matches
pyproject.toml, publishes to PyPI, then attaches the wheel + sdist to a GitHub Release.
One-time PyPI setup (project owner): add a Pending Publisher on pypi.org with project croniq-runner, owner nuetzliches, repository croniq, workflow python-sdk-release.yml, environment pypi.
License
Dual-licensed under MIT or Apache-2.0, matching the rest of the Croniq repository.
Metadata
Release files for croniq-runner 0.7.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 | |
|---|---|---|---|
| croniq_runner-0.7.0.tar.gz | 37.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| croniq_runner-0.7.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 76.9 kB
Release files / croniq_runner-0.7.0.tar.gz
| Download URL | croniq_runner-0.7.0.tar.gz |
|---|---|
| Size | 37.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5f99a1cd79143b5ea268d349ac357176629526acc8f94ab261a477cd3ada75b9
|
|
BLAKE2b-256 checksum How to use checksums |
3bd6cd5b8090b5450eb09dd604255e6bb6c6222b15f85b5e4c357f3324e7425c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency logRelease files / croniq_runner-0.7.0-py3-none-any.whl
| Download URL | croniq_runner-0.7.0-py3-none-any.whl |
|---|---|
| Size | 39.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f75e745bd35ea3242781f8daeae845e64bf09ee5c6bece811cf338bdb0ea3c79
|
|
BLAKE2b-256 checksum How to use checksums |
0bfda0118cb84048e3ff7e612b43a7e9ee3546315e4d49a685ae388f625336cf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency log