Skip to main content

Python SDK

Package: rochallor-sdk (PyPI)

The Python SDK supports REST only. A gRPC transport is not implemented.

Key types

Module Type Purpose
workflow_sdk.client.rest RestEngineClient(base_url, timeout?) REST client using httpx
workflow_sdk.client.interface EngineClient protocol Transport abstraction
workflow_sdk.handler.registry HandlerRegistry Maps jobType strings to handler callables
workflow_sdk.runner.runner Runner(client, registry, worker_id, ...) Poll/dispatch loop
workflow_sdk.runner.runner Runner.run(stop_event?) Blocks until stop_event is set or SIGINT/SIGTERM received
workflow_sdk.errors NonRetryableError Raise from a handler to bypass the retry budget
workflow_sdk.errors EngineClientError Raised on non-2xx HTTP responses — carries .status_code and .message

The handler function receives a dict with the following keys:

Key Type Description
id str Job ID
jobType str Job type string
instanceId str Workflow instance ID
stepExecutionId str Step execution ID
retriesRemaining int Retry budget remaining
variables dict Current workflow variables

Return a dict of variables to merge into the instance, or None / {} for no output.


How the runner works

HandlerRegistry() + registry.register(...) just build a jobType → callable map in memory — no connection, no I/O. The Runner is what drives everything:

  1. A loop fires every poll_interval seconds (default 0.5 s) and calls POST /v1/jobs/poll.
  2. The engine claims available jobs atomically with FOR UPDATE SKIP LOCKED and returns them.
  3. Each job is submitted to a ThreadPoolExecutor (bounded by parallelism, default 64).
  4. The thread calls your registered handler, then calls complete_job or fail_job based on the result.

Error handling: raise a plain Exception → fail_job(retryable=True) → engine retries up to retryCount. Raise NonRetryableError → fail_job(retryable=False) → fails immediately regardless of retry budget.

For the full model (sequence diagram, retry flow, graceful shutdown), see architecture.md — Worker polling model.


Minimal example

import threading
from workflow_sdk.client.rest import RestEngineClient
from workflow_sdk.handler.registry import HandlerRegistry
from workflow_sdk.runner.runner import Runner

client   = RestEngineClient("http://localhost:8080")
registry = HandlerRegistry()

def process_order(ctx):
    order_id = ctx["variables"]["orderId"]
    # ... process order ...
    return {"processed": True, "orderId": order_id}

registry.register("process-order", process_order)

# Runner installs SIGINT/SIGTERM handlers automatically when stop_event is None
Runner(client=client, registry=registry, worker_id="py-worker-1").run()

Full demo — multiple handlers, non-retryable errors, context manager

import logging
import threading
from datetime import datetime, timezone

from workflow_sdk.client.rest import RestEngineClient
from workflow_sdk.errors import EngineClientError, NonRetryableError
from workflow_sdk.handler.registry import HandlerRegistry
from workflow_sdk.runner.runner import Runner

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)


def validate_application(ctx: dict) -> dict:
    """Validate the loan application — non-retryable on bad input."""
    applicant_id = ctx["variables"].get("applicantId")
    if not applicant_id:
        raise NonRetryableError("applicantId is required and must be a non-empty string")

    retries_left = ctx["retriesRemaining"]
    log.info("Validating applicant %s (retries left: %d)", applicant_id, retries_left)
    # ... call validation service ...
    return {
        "validationPassed": True,
        "validatedAt": datetime.now(timezone.utc).isoformat(),
    }


def credit_score(ctx: dict) -> dict:
    """Fetch credit score — retryable on transient errors."""
    applicant_id = ctx["variables"]["applicantId"]
    score = fetch_credit_score(applicant_id)  # may raise on network error → retryable
    return {"creditScore": score}


def send_notification(ctx: dict) -> dict | None:
    """Send email notification — no output variables."""
    email = ctx["variables"].get("email", "")
    log.info("Sending notification to %s (jobId=%s)", email, ctx["id"])
    # ... send email ...
    return {"notificationSent": True}


def upload_workflow(client: RestEngineClient) -> None:
    """Upload the loan workflow definition if not already present."""
    try:
        client.get_definition("loan-processing-workflow")
        log.info("Definition already exists — skipping upload")
        return
    except EngineClientError as e:
        if e.status_code != 404:
            raise

    definition = {
        "id":   "loan-processing-workflow",
        "name": "Loan Processing Workflow",
        "steps": [
            {"id": "validate",      "name": "Validate Application",
             "type": "SERVICE_TASK", "jobType": "validate-application", "nextStep": "score"},
            {"id": "score",         "name": "Credit Score Check",
             "type": "SERVICE_TASK", "jobType": "credit-score",         "nextStep": "notify"},
            {"id": "notify",        "name": "Send Notification",
             "type": "SERVICE_TASK", "jobType": "send-notification",    "nextStep": "end"},
            {"id": "end",           "name": "Done",                     "type": "END"},
        ],
    }
    result = client.upload_definition(definition)
    log.info("Uploaded definition: %s v%s", result["id"], result["version"])


def main() -> None:
    # RestEngineClient is a context manager — closes the httpx.Client on exit
    with RestEngineClient("http://localhost:8080", timeout=10.0) as client:
        upload_workflow(client)

        registry = HandlerRegistry()
        registry.register("validate-application", validate_application)
        registry.register("credit-score",         credit_score)
        registry.register("send-notification",    send_notification)

        runner = Runner(
            client=client,
            registry=registry,
            worker_id="py-worker-1",
            parallelism=16,
            poll_interval=0.25,
        )
        # Blocks until SIGINT/SIGTERM; gives in-flight jobs a bounded drain period
        runner.run()


if __name__ == "__main__":
    main()


def fetch_credit_score(applicant_id: str) -> int:
    return 720  # placeholder

Full REST admin API

RestEngineClient exposes all engine operations, not just the worker interface:

from workflow_sdk.client.rest import RestEngineClient

client = RestEngineClient("http://localhost:8080")

# Definitions
result  = client.upload_definition(definition_dict)   # → {"id": ..., "version": ...}
defn    = client.get_definition("my-workflow")         # → definition dict
page    = client.list_definitions(keyword="loan", page=0, page_size=20)

# Instances
instance = client.start_instance(
    "my-workflow",
    variables={"applicantId": "A-001"},
    business_key="APP-2024-001",          # optional correlation key
)
state    = client.get_instance(instance["id"])
history  = client.get_instance_history(instance["id"])   # → list of step execution dicts
client.cancel_instance(instance["id"], reason="User requested")

# User tasks
client.complete_user_task(task_id, completed_by="jane@example.com", result={"approved": True})

Kafka Dispatch (Opt-In)

The Python SDK supports push-based job dispatch via Kafka, providing a more scalable alternative to polling.

Usage

from workflow_sdk.runner.kafka_runner import KafkaRunner

# Setup client and registry as before
runner = KafkaRunner(
    worker_id=worker_id,
    brokers="localhost:9092",
    client=client,
    registry=registry
)

runner.run()

At-least-once delivery and idempotent handlers

KafkaRunner delivers jobs with at-least-once semantics. An in-process dedup window (default 10 min) absorbs most duplicates transparently — but a handler can be invoked more than once for the same job_id when:

  • The relay was down longer than dedup_window_seconds before republishing.
  • This runner restarted between the original message and a relay-republished duplicate.

Handlers must be idempotent. Use a stable business operation key from variables (for example, an invoice ID) for external side effects. jobId identifies one delivery attempt and changes on automatic retry or lease recovery; it only deduplicates redelivery of that same attempt.

def send_invoice(job):
    invoice_id = job["variables"]["invoiceId"]
    if db.invoice_already_sent(invoice_id):
        return {}
    return send_invoice_to_customer(job["variables"], idempotency_key=invoice_id)

registry.register("send-invoice", send_invoice)

Enforce the operation key with a database unique constraint or pass it to the external service as its idempotency key. A check-before-send alone does not prevent concurrent duplicates.

The engine's complete_job / fail_job calls are already idempotent — a second call with the same job_id is a no-op. Only your external side-effects need to be guarded.


TLS / SASL (production)

The basic config above connects to brokers in plaintext with no authentication. For production, pass standard librdkafka properties through extra_kafka_config — anything set there is merged into the consumer config (and overrides defaults). Full key reference: librdkafka CONFIGURATION.md.

import os
from workflow_sdk.runner.kafka_runner import KafkaRunner

# SASL/SCRAM-SHA-512 over TLS — common for MSK, Confluent Cloud, Aiven, Redpanda
runner = KafkaRunner(
    worker_id="py-worker-1",
    brokers="kafka-1.prod:9093,kafka-2.prod:9093",
    client=client,
    registry=registry,
    extra_kafka_config={
        "security.protocol": "SASL_SSL",
        "sasl.mechanism":    "SCRAM-SHA-512",
        "sasl.username":     os.environ["KAFKA_SASL_USERNAME"],
        "sasl.password":     os.environ["KAFKA_SASL_PASSWORD"],
        # Optional — only when the broker uses a private CA
        # "ssl.ca.location": "/etc/kafka/ca.pem",
    },
)
runner.run()

Other mechanisms:

# SASL/PLAIN over TLS
extra_kafka_config = {
    "security.protocol": "SASL_SSL",
    "sasl.mechanism":    "PLAIN",
    "sasl.username":     "...",
    "sasl.password":     "...",
}

# SASL/OAUTHBEARER (AWS MSK IAM, Azure Event Hubs, custom IdP)
# Use confluent-kafka's OAuthBearer token refresh callback.
extra_kafka_config = {
    "security.protocol":     "SASL_SSL",
    "sasl.mechanism":        "OAUTHBEARER",
    "oauth_cb":              lambda cfg: (fetch_access_token(), time.time() + 900),
}

# Mutual TLS only (no SASL)
extra_kafka_config = {
    "security.protocol":      "SSL",
    "ssl.certificate.location": "/etc/kafka/client.crt",
    "ssl.key.location":         "/etc/kafka/client.key",
    "ssl.ca.location":          "/etc/kafka/ca.pem",
}

Plaintext brokers should be restricted to local dev (docker compose) — never deploy without SASL_SSL (or SSL with mutual TLS) on a shared network.


KafkaRunner constructor reference

Parameter Type Default Description
worker_id str (required) Unique identifier for this worker.
brokers str (required) Comma-separated list of Kafka brokers.
client EngineClient (required) REST client for completion callbacks.
registry HandlerRegistry (required) Maps job types to handlers.
dedup_window_seconds float 600.0 Window (seconds) for in-memory deduplication (default 10m).
extra_kafka_config dict None Overrides merged into the Kafka Consumer config — set security.protocol, sasl.*, ssl.* here for production.

Runner constructor reference (Polling Mode)

Parameter Type Default Description
client EngineClient (required) REST client.
registry HandlerRegistry (required) Maps job types to handlers.
worker_id str (required) Unique identifier for this worker process.
parallelism int 64 Maximum claimed jobs, including running handlers and pending callbacks.
poll_interval float 0.5 Seconds between poll rounds when the queue is empty.

Polling callback recovery (R06)

Polls request only free capacity after pruning completed futures. A claimed job holds its slot through handler execution and callback acknowledgement; the executor does not queue additional leased jobs. The handler result or failure reason/retryability is snapshotted and retried independently of the handler. Completion transport errors never become business failures.

REST and gRPC preserve lockExpiresAt. Retry backoff starts at 100 ms with ±20% jitter and a 30-second cap, bounded by the existing lease and shutdown event. Built-in transports use at most 5-second I/O timeouts (or the remaining lease, if shorter); HTTP timeouts apply per I/O phase. There is no heartbeat. Expired/invalid lease metadata prevents dispatch; absent metadata from a legacy custom client permits one callback attempt. Client method signatures are unchanged; custom implementations must provide bounded I/O themselves.

Shutdown stops claiming/retrying and bounds drain by shutdown_timeout; it raises if threads remain. Python cannot forcibly cancel arbitrary handler side effects: a handler still running after expiry retains its slot until it returns, then sends no stale callback. Keep external side effects idempotent and normally finish within the baseline 30-second lease.

Monitor workflow_sdk_callback_pending, workflow_sdk_callback_retries_total, workflow_sdk_callback_oldest_pending_seconds, and workflow_sdk_callback_rejected_total, using finite dispatch and callback labels. Job IDs are logged. Rising pending age calls for checking transport/engine health and authorization; permanent HTTP 4xx (except 408/429) and permanent gRPC statuses stop callback delivery and leave recovery to the lease. Canary a drained worker group and inspect actual workflow outcomes. Rollback pins the previous build and may restore polling callback/claim defects.

R06/R07 address polling and Kafka callback recovery. Heartbeat for long handlers remains a later release.

Package verification: python -m pytest, then python scripts/verify-package.py. It builds wheel and sdist, checks fresh external consumers, and rejects a deliberately broken distribution. E2E installs the checksummed wheel; publishing downloads those same verified bytes and never rebuilds.

Kafka delivery and recovery (R07)

Auto-commit and auto-offset-store are disabled, including when caller overrides request them. Each partition pauses with one outstanding record; a consumer thread continues polling for membership while a separate handler thread retains the result and retries its callback. Offsets commit synchronously and explicitly as record offset + 1 only after acknowledgement. A failure at N cannot be hidden by a later commit at N+1. Dedup records are written after acknowledgement; pending delivery does not expire with dedup TTL.

Revocation/loss cancels the old owner's callback; completion from an old future cannot commit. Shutdown stops claims and callbacks, leaves the group, and drains handler threads. Arbitrary Python handler side effects cannot be forcibly cancelled. Lost acknowledgement/crash can cause redelivery, so use stable business idempotency keys.

Malformed protobuf/empty job IDs and permanent callback rejection stop the runner with the record uncommitted. Valid job IDs with invalid payloads or missing handlers produce a non-retryable failure callback, which must itself be acknowledged. KafkaRunner.run() propagates consumer errors to the caller; restart after fixing the payload, credentials or infrastructure. Do not reset offsets to bypass pending work. Extra Kafka configuration remains available for TLS/SASL, but auto-offset/commit policy and per-job consumer group are reserved.

KafkaRunner(..., metrics=Metrics(registry=...)) exposes the same callback pending/retry/oldest/rejected metrics with dispatch="kafka", plus workflow_sdk_kafka_record_errors_total. Logs include job/dedup/topic/partition/offset. Canary a drained group and compare pending age, lag and actual workflow outcomes. Rollback pins the previous wheel/worker and may restore A1. No engine or DB changes are required. Native poll, synchronous commit and assignment handling follow the Confluent Python API.

Optional polling heartbeat (1.1.0)

Heartbeat is off by default. Enable only after every engine replica supports ExtendJobLease (engine candidate v1.2.0 / R15):

runner = Runner(client, registry, "worker-1", parallelism=16,
                heartbeat=True, lease_seconds=30, heartbeat_interval=10,
                shutdown_timeout=5)

The first renewal must be accepted before the handler starts. Renewal continues while the handler executes and while its immutable completion/failure callback awaits acknowledgement. Each renewal has a transport timeout of at most 5 seconds, limited by the current lease; transient failures back off within that lease. A terminal/expired/wrong-worker attempt raises LeaseLostError; the runner sends no failure callback for ownership loss. Unsupported engines/clients raise HeartbeatUnsupportedError through Runner.run() and stop intake.

EngineClient keeps its original method signatures. LeaseExtender is a separate optional protocol. Built-in clients additionally accept poll_jobs(..., lease_seconds=60); 0 means the 30-second engine default, otherwise 5–3600 seconds. The runner communicates this optional setting through request_lease() in workflow_sdk.client.deadline, leaving legacy custom clients unchanged. Custom clients implementing heartbeat should honor current_timeout() and, if they support custom polling leases, current_lease_seconds().

Handlers may observe ctx["cancel_event"], a threading.Event, to stop external work on lease loss/shutdown. Cancellation is a request, not proof that a thread has stopped: the runner keeps its capacity slot until the handler returns. run() stops intake and waits at most shutdown_timeout after a bounded poll returns; it raises if handlers remain and rejects another run on the same runner until they finish. Python cannot force-kill those threads, and they can keep the process alive. A process supervisor must enforce the final process shutdown deadline.

Renewal uses a separate thread outside the handler executor. Pure Python CPU work allows thread switching (covered by a regression test). Native code holding the GIL, a suspended process, or OS starvation can still prevent renewal; isolate that work in another process or use a verified longer lease. Side effects require idempotency even with heartbeat.

Monitor workflow_sdk_heartbeat_total{result="accepted|retry|lease_lost|rejected"}, callback pending/retry metrics and replacement attempts. Canary with heartbeat off, then enable on a small worker group while measuring DB writes and latency. To roll back, stop intake for tasks dependent on renewal, drain them, then disable heartbeat and pin the prior SDK. Disable/drain heartbeat workers before rolling back the engine. Generate checked-in stubs with Python 3.12 and pip install grpcio-tools==1.60.0 setuptools==80.9.0, then run python scripts/generate-proto.py from the SDK directory.

Metadata

Release files for rochallor-sdk 1.1.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 rochallor-sdk 1.1.0
File Size Uploaded
rochallor_sdk-1.1.0.tar.gz 40.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for rochallor-sdk 1.1.0
File Interpreter ABI Platform
rochallor_sdk-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 78.2 kB

Release files / rochallor_sdk-1.1.0.tar.gz

Download URL rochallor_sdk-1.1.0.tar.gz
Size 40.8 kB
Tags Source
SHA-256 checksum
How to use checksums
8ca6c6912f7258f96c232fe92bee79017d04718fced9c8450ceaccceee1da53d
BLAKE2b-256 checksum
How to use checksums
bd69b2eb19e1ea1a9517bec5c4bb3cb93fe1b727513459d1bf19ada76ff34da4
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 12, 2026.

Transparency log

Release files / rochallor_sdk-1.1.0-py3-none-any.whl

Download URL rochallor_sdk-1.1.0-py3-none-any.whl
Size 37.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f767efb5761b2159904ba2ebc9d02582145bc9ba1af0224fba354a4f11e275f2
BLAKE2b-256 checksum
How to use checksums
86565a1035e27b1632e2b7436db841abf2e32c917554c5f578397d4e6d187d95
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 12, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

1.0.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