Skip to main content

Croniq Runner SDK for Python

PyPI License: MIT OR Apache-2.0

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.yaml snake_case wire format.
  • Two-tier logging:
    • ctx.logger — standard logging.Logger scoped with execution_id, job_key, runner_id, attempt.
    • ctx.log_writer — streaming channel backed by a bounded asyncio.Queue with batching (32 events / 200 ms / max 100 per POST), drained before the ack.
  • Server-side cancellation — PollResponse.cancel is honoured via ctx.cancellation (an asyncio.Event). Handlers should await ctx.cancellation.wait() between checkpoints, or just use await asyncio.sleep(...) — the runner cancels the underlying task when the event fires.
  • Lease renewal — periodic POST /v1/work/renew heartbeat for each in-flight execution.
  • Self-registration — pass schedule="5m" to add_handler and the runner POSTs to /v1/jobs/register on startup.
  • OpenTelemetry — opt-in via the [otel] extra; spans wrap each handler invocation when opentelemetry-api is 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, and http:// on localhost, 127.0.0.0/8 or ::1 — so the http://localhost:4000 quickstart default keeps working;
  • refused: http:// on any other host, with a ValueError naming 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).

  1. Bump version = "X.Y.Z" in pyproject.toml.
  2. Add a ## [X.Y.Z] section to CHANGELOG.md.
  3. Commit, push to main.
  4. Tag and push:
    git tag python-sdk-vX.Y.Z
    git push origin python-sdk-vX.Y.Z
    
  5. 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)

Source distribution for croniq-runner 0.7.0
File Size Uploaded
croniq_runner-0.7.0.tar.gz 37.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for croniq-runner 0.7.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

0.7.2

2 release files

0.7.1

2 release files

This release

0.7.0 This release

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page