pykicak
pykicak makes it easy to build RabbitMQ-connected agent nodes with idempotent message
processing. It provides abstract base classes — Injector and Executor — that handle RabbitMQ
connection setup and queue/exchange declaration, so subclasses only need to implement their
specific behavior. Delivery is at-least-once, so the processing your agents implement must be
idempotent; see Requirement: idempotent processing.
- Injector — short-lived node that creates and publishes an initial message to start a processing flow (e.g. a document ingestion trigger).
- Executor — long-running node that consumes messages from a queue, executes them, and optionally publishes the results to an exchange. Messages that can never be processed are dead-lettered instead of blocking the queue. Can operate in "terminator" mode where it consumes but does not publish (e.g. a database writer or quality checker).
Requirement: idempotent processing
pykicak is designed for idempotent agents only. It delivers every message at least once, never exactly once: it never loses a message, and the price is that a message can be processed, and its results published, more than once. Every agent built on pykicak must therefore be idempotent: processing the same message twice, or twice at the same time, must leave the system in the same state as processing it once. Using pykicak for processing that is not idempotent can corrupt data or repeat external actions.
A message is processed more than once when:
- an Executor is killed or stopped without
stop()(crash,SystemExit,KeyboardInterrupt, a supervisor's forced kill) while it processes a message, so RabbitMQ redelivers the message, which is executed again, and its results are published again if they had already been published; a gracefulstop()finishes the message first and avoids this; - the connection is lost while an Executor publishes its results, so the input is executed again;
execute()runs longer thanheartbeat_seconds, so the input is redelivered while it is still being executed, and with several instances it runs concurrently on another instance;execute()raisesTransientProcessingError, so it is called again, and requeued aftermax_processing_attemptsfailed calls, so it is executed again later;- RabbitMQ refuses an Executor's results (
UnroutableErrororNackError), so the input is requeued and executed again, and results confirmed before the refusal are published again; - the acknowledgement fails, for example because the connection was lost right after the results were published, so RabbitMQ redelivers the input, which is executed again and its results published again;
- an Injector loses its connection before RabbitMQ confirms a message, so it publishes the message again.
What this requires from an application:
- Idempotent side effects in
execute(). Enforce them in the database (unique constraints, upserts keyed by a message ID), not by checking first and writing afterwards, which fails when two instances run the same message concurrently. This includes partial work done by a call that then raisesTransientProcessingError. - Stable message IDs. Every message has a required
message_id(see Message types). Give each message a unique ID when it is first created, and derive the IDs of results from the input's ID (for example withuuid.uuid5), not fromuuid4(), so that executing an input again produces results with the same IDs, which downstream agents can deduplicate. - Idempotent downstream agents. Every Executor in a pipeline receives duplicates, so the requirement applies to all of them.
- No at-most-once actions without a deduplication key. Actions that must not be repeated, such as sending an email, charging a payment, or calling a non-idempotent API, need an idempotency key derived from the message ID, or must not be performed by a pykicak agent.
Installation
pip install pykicak
Requires Python 3.12+. Depends on pika for the RabbitMQ
connection.
Configuration: the .kicak file
Applications load RabbitMQ connection details with KicakConfig.from_file(path) and pass
the resulting config to an agent. The library does not select a config path or read environment
variables. A .kicak file is a simple KEY=VALUE text file, read as UTF-8 on every platform
(a leading byte order mark is allowed); blank lines and lines starting with # are ignored.
See .kicak.example for a template.
| Key | Description |
|---|---|
RABBIT_MQ_USERNAME |
RabbitMQ username |
RABBIT_MQ_PASSWORD |
RabbitMQ password |
RABBIT_MQ_HOST |
RabbitMQ host |
RABBIT_MQ_PORT |
RabbitMQ port |
RABBIT_MQ_VIRTUAL_HOST |
RabbitMQ virtual host |
Never commit a real .kicak file; .kicak files are excluded by .gitignore.
Missing or unreadable files, invalid UTF-8, malformed lines (including a line with an empty key),
duplicate keys, and missing required keys raise KicakConfigError. The port is converted to an
integer when rabbitmq_port is accessed, and a value that is not an integer from 1 to 65535 also
raises KicakConfigError.
KicakConfig keeps values out of its repr() and its errors, so they do not reach logs or crash
reporters: the repr lists only the keys (KicakConfig(keys=['RABBIT_MQ_HOST', ...])), and a
malformed line or invalid value is reported by its line number or key, never its content.
The .kicak file does not hold queue, exchange, or binding names — that is application
topology, not connection configuration. See RabbitMQ topology below.
RabbitMQ topology
Queue and exchange names are declared outside the Kicak abstract classes, in your application's
own messaging/ module (e.g. messaging/queues.py, messaging/exchanges.py,
messaging/bindings.py), and passed into agent constructors in code. This keeps a single
owner for each queue/exchange name, so independent agents reference the same definitions
instead of inventing their own. See examples/poc/messaging/
for a worked example.
| Constructor arg | Used by | Description |
|---|---|---|
exchange_name |
KicakInjectorAbstract (required) |
Exchange to publish messages to |
source_queue |
KicakExecutorAbstract (required) |
Queue to consume messages from |
source_exchange |
KicakExecutorAbstract (required) |
Upstream exchange source_queue is bound to |
dead_letter_exchange |
KicakExecutorAbstract (required) |
Exchange that rejected deliveries from source_queue are routed to |
dead_letter_queue |
KicakExecutorAbstract (required) |
Queue bound to dead_letter_exchange that retains rejected deliveries |
destination_exchange |
KicakExecutorAbstract (optional) |
Exchange that the results returned by execute() are published to |
queue_type |
KicakExecutorAbstract (optional, default QueueType.CLASSIC) |
RabbitMQ type of both source_queue and dead_letter_queue: QueueType.CLASSIC or QueueType.QUORUM (see Queue type) |
max_processing_attempts |
KicakExecutorAbstract (optional, default 3) |
How many times execute() is called for one delivery while it raises TransientProcessingError, before the delivery is requeued (see Error handling and dead-lettering) |
heartbeat_seconds |
KicakExecutorAbstract (optional, default 600) |
Heartbeat timeout of the Executor's connections; must exceed the longest execute() (see Long-running execute()) |
Constructor arguments after config are keyword-only, for both the Injector and the Executor
(exchange_name="...", source_queue="...", and so on). An Executor declares its own durable input
queue of type queue_type with the x-dead-letter-exchange argument set to dead_letter_exchange,
and binds that queue to the durable fanout source exchange. It also declares the durable fanout
dead-letter exchange and the durable dead-letter queue, of the same type, bound to it, so rejected
messages are kept for inspection or replay. It may also
declare a durable fanout destination_exchange. Executors never create or bind downstream queues.
Each downstream consumer owns its queue and binding. Omit destination_exchange for terminator
mode.
The dead-letter exchange must differ from the source and destination exchanges, and the dead-letter queue must differ from the source queue.
The library assumes this deployment model:
- Each queue belongs to one Executor type, which can run as several instances. To scale an Executor, run more processes of it with the same configuration; they consume from the same queue and RabbitMQ shares its messages between them (see Scaling an Executor).
- Permanent topology. Once declared, exchanges and queues stay in the broker, and agents never
delete them. Agents declare their topology idempotently each time they connect, including every
reconnection, so an agent still running recreates anything removed in the meantime. To redesign
the topology, stop injecting, let the Executors drain every queue, stop every instance with
stop(), remove the whole structure, then start the new Executors, which declare the new structure. - Topology installed before publishing. Start each Executor at least once before anything
publishes to its source exchange. Until its queue is declared and bound, publishing to that
exchange fails with
UnroutableError(see Delivery guarantees).
RabbitMQ queue arguments cannot change after a queue is created. If source_queue already exists
without the same x-dead-letter-exchange argument, or either queue exists with another queue type,
connecting fails with 406 PRECONDITION_FAILED and is not retried. Delete the old queue (after
draining it) before deploying an Executor that declares it differently.
Queue type
queue_type selects the RabbitMQ queue type of the Executor's input queue and its dead-letter
queue; both always have the same type. Pass a member of the exported QueueType enum:
QueueType.CLASSIC(the default) is a queue stored on one broker node.QueueType.QUORUMis a queue replicated across the nodes of a RabbitMQ cluster, which keeps its messages when a node fails. It also works on a single node.
from pykicak import QueueType
executor = ClassifyingExecutor(
config,
source_queue="classifier",
source_exchange="items.inbox",
dead_letter_exchange="classifier.dlx",
dead_letter_queue="classifier.dlq",
destination_exchange="items.classified",
queue_type=QueueType.QUORUM,
)
QueueType is a string enum, so the values "classic" and "quorum" work too, for example when
the type comes from your own configuration; any other value raises ValueError.
The type is always declared explicitly (x-queue-type), so a default queue type configured for the
vhost never applies. Queues declared without a type, for example by another tool, are classic
queues and keep working with the default QueueType.CLASSIC. To change the type of an existing
queue, follow the topology redesign steps above: drain it, stop every instance, delete the queue,
then start the Executors with the new queue_type.
Quorum queues count the deliveries of each message. On RabbitMQ 4.0 and newer they apply a default
delivery limit of 20: a message delivered 21 times is dead-lettered with the x-death reason
delivery_limit instead of being delivered again. Every requeue counts, whether after
TransientProcessingError exhausted max_processing_attempts, or after a lost connection or a hard
stop. A classic queue has no such limit and requeues a message indefinitely. The limit can be
changed with a RabbitMQ policy (delivery-limit).
Message types
Messages sent and received by agents are declared as KicakMessage subclasses, independent of
any agent class, so the same type can be imported by both the publishing side (injector/executor)
and the consuming side (executor):
import dataclasses
from pykicak import KicakMessage
@dataclasses.dataclass(frozen=True, slots=True)
class CrawledItem(KicakMessage):
url: str
content: str
item = CrawledItem(url="https://example.com", content="...", message_id="5f0c7b1e-...")
Every message has a message_id, a field that KicakMessage declares for all message types, so
your classes do not declare it. Idempotent processing depends on it (see
Requirement: idempotent processing):
- It is required and keyword-only, with no default. A generated default would give the results of
a re-executed input new IDs, so each message's creator chooses it: a new ID (for example
str(uuid.uuid4())) where a message is first created, and an ID derived from the input's ID for the results of an Executor. - It must be a non-empty string of at most 255 bytes in UTF-8; otherwise creating the message raises
TypeErrororValueError, and a received message is dead-lettered as malformed. - It is sent in the message body and as the AMQP
message_idproperty, and the library includes it in its log records.
A message class that defines __post_init__ must call KicakMessage.__post_init__(self), which
validates the ID. Zero-argument super() does not work in a slots=True dataclass on Python 3.12.
Fields must be JSON-serializable (store timestamps as ISO-8601 strings, not datetime objects).
Decoding checks that the body is a JSON object with exactly the class's fields; it does not convert
or check the types of their values, so a nested dataclass, for example, is decoded as a dict.
Validate values in execute() and raise MalformedMessageError for invalid ones.
Usage
The examples use the CrawledItem message defined above and literal topology names for brevity.
In an application, define these names centrally in its messaging/ module as described in
RabbitMQ topology.
Executor (terminator mode — no publishing):
from pykicak import (
KicakConfig,
KicakExecutorAbstract,
MalformedMessageError,
TransientProcessingError,
)
class SaveToDbExecutor(KicakExecutorAbstract[CrawledItem]):
def message_type(self) -> type[CrawledItem]:
return CrawledItem
def execute(self, message: CrawledItem) -> None:
if not message.url:
raise MalformedMessageError("url is empty") # dead-lettered
try:
... # upsert the item into a database, keyed by message.message_id
except ConnectionError as error:
raise TransientProcessingError("database unavailable") from error # retried
return None # a terminator publishes nothing
config = KicakConfig.from_file(".kicak")
executor = SaveToDbExecutor(
config,
source_queue="db-writer",
source_exchange="items.inbox",
dead_letter_exchange="db-writer.dlx",
dead_letter_queue="db-writer.dlq",
)
executor.run()
Injector (publishes an initial message):
import uuid
from pykicak import KicakConfig, KicakInjectorAbstract
class StartProcessingInjector(KicakInjectorAbstract):
def generate(self) -> CrawledItem:
# A new ID where the message is first created; it never changes afterwards
return CrawledItem(message_id=str(uuid.uuid4()), url="https://example.com", content="...")
config = KicakConfig.from_file(".kicak")
injector = StartProcessingInjector(config, exchange_name="items.inbox")
injector.run() # publishes once, then returns
Each injection is short-lived. run() calls generate() and, if it returns a message, opens a
connection, publishes the message, waits for RabbitMQ's confirmation, and closes the connection. If
generate() returns None, no connection is opened. Connecting and publishing are attempted up to
three times: immediately, after 1 second, and after 2 more seconds, each time on a new connection
and with the same message.
run() returns normally only if the message was injected. Otherwise it closes the connection and
raises InjectionError. Its __cause__ is the error that ended the last attempt, for example
pika.exceptions.UnroutableError when no queue is bound to the exchange yet, or a pika connection
error when RabbitMQ is unreachable. Its message attribute is the message that was not injected,
and its text names the message type and message_id, never the payload. Catch it to learn that the
message was not injected:
from pykicak import InjectionError
try:
injector.run()
except InjectionError as error: # nothing was injected; the connection is already closed
... # e.g. return an error to the API caller, or exit with a non-zero status
publish(), used with connect() or a with block, raises InjectionError in the same way. It
raises RuntimeError without a connection, including after close() or the end of the with
block.
Errors from your own generate(), or from serializing its message, are not wrapped and propagate
unchanged, because they are application bugs rather than delivery failures.
injector.run(interval_seconds=60) repeats the injection every 60 seconds until the process ends;
there is no stop(). No connection is kept open between injections, so the interval is not limited
by RabbitMQ's heartbeat. A failed injection ends run() with its error, as above, so no message is
skipped silently.
Executor (with publishing to an exchange):
import dataclasses
import uuid
from pykicak import KicakConfig, KicakExecutorAbstract, KicakMessage
@dataclasses.dataclass(frozen=True, slots=True)
class ClassifiedItem(KicakMessage):
url: str
category: str
class ClassifyingExecutor(KicakExecutorAbstract[CrawledItem]):
def message_type(self) -> type[CrawledItem]:
return CrawledItem
def execute(self, message: CrawledItem) -> ClassifiedItem:
# The result's ID is derived from the input's, so executing the input again
# publishes a result with the same ID, which downstream agents can deduplicate
result_id = uuid.uuid5(uuid.NAMESPACE_URL, f"classified:{message.message_id}")
return ClassifiedItem(message_id=str(result_id), url=message.url, category="news")
config = KicakConfig.from_file(".kicak")
executor = ClassifyingExecutor(
config,
source_queue="classifier",
source_exchange="items.inbox",
dead_letter_exchange="classifier.dlx",
dead_letter_queue="classifier.dlq",
destination_exchange="items.classified",
)
executor.run()
execute() returns a message, a sequence of messages, or None. The Executor publishes the results
itself, only after execute() succeeds, so a failed message never produces partial output.
Subclassing KicakExecutorAbstract[CrawledItem] tells type checkers that execute() receives a
CrawledItem. A subclass of the plain KicakExecutorAbstract works too; its execute() then
receives a KicakMessage.
All queues/exchanges are declared automatically (idempotently) when the agent connects.
Error handling and dead-lettering
The Executor decides the outcome of every delivery from how execute() ends:
| Outcome | Cause | Delivery is… | Executor |
|---|---|---|---|
| Success | execute() returns |
results published, then acknowledged | continues |
| Deterministic failure | missing body, undecodable body (not a JSON object, missing or unexpected fields, no valid message_id), or MalformedMessageError raised by execute() |
rejected with basic_nack(requeue=False), so RabbitMQ routes it to the dead-letter exchange |
continues |
| Transient failure | TransientProcessingError raised by execute() |
retried in-process; if every attempt fails, requeued with basic_nack(requeue=True) |
continues |
| Unexpected failure | any other exception from execute(), or results that are not KicakMessages, cannot be serialized, or are returned by a terminator |
dead-lettered as above | logs the error and raises it, stopping |
| Results refused by RabbitMQ | UnroutableError (no queue bound to the destination exchange) or NackError while publishing results |
requeued with basic_nack(requeue=True); the message is not at fault |
logs the error and raises it, stopping |
| Connection lost while publishing results | a transient connection error from a result publish | already requeued by RabbitMQ when the connection dropped; the results are discarded, not re-sent | reconnects and receives the message again |
execute() is called up to max_processing_attempts times (default 3) while it raises
TransientProcessingError, with 0.5- and 1.0-second delays that double up to a 30-second cap. A
requeued message is delivered again later. With a classic queue, a failure that never clears keeps
being retried; a quorum queue dead-letters the message once it reaches its delivery limit (see
Queue type).
Dead-lettered messages keep their original body. RabbitMQ adds an x-death header recording the
source queue and the reason: rejected when the Executor rejected the message, which it logs at
ERROR level, or delivery_limit when a quorum queue's delivery limit was reached.
Results are published before the delivery is acknowledged, so no result is lost. If the process stops between the two, RabbitMQ redelivers the message and its results are published again; this is one of the reasons agents must be idempotent (see Requirement: idempotent processing).
The Executor never re-sends a result on a new connection. An acknowledgement can only be sent on the channel that delivered the message, and RabbitMQ requeues the delivery as soon as that connection is lost. Re-sending the results would therefore always duplicate them once the message is executed again. Instead, the Executor discards the results, reconnects, and executes the redelivered message, publishing its results once. Results that RabbitMQ had already confirmed before the connection was lost are published again with the rest.
Running and stopping an Executor
The Executor takes one message at a time: it sets a prefetch count of 1, so RabbitMQ delivers the next message only after the previous one has been acknowledged or rejected. Messages waiting in the queue are therefore never held, unprocessed, by the Executor, and their RabbitMQ delivery timeout does not start early.
run() consumes until you call stop(), and then returns normally.
Graceful stop
stop() asks the Executor to finish its current work and then return from run() without taking
another message:
- While a message is being processed, the Executor finishes it:
execute()completes, its results are published, and the message is acknowledged (or requeued or dead-lettered, as usual).run()then returns instead of waiting for the next message. - While waiting for a message,
run()returns within about a second, without taking one. - While reconnecting after a lost connection,
run()stops retrying and returns within about a second. - A message delivered after the stop request, before the Executor notices it, is not processed; RabbitMQ returns it to the queue when the connection closes.
stop() only sets a flag, so it is safe to call from any thread and from a signal handler. Connect
it to the signals your process supervisor sends, for example:
import signal
executor = ClassifyingExecutor(config, ...) # topology arguments as above
for stop_signal in (signal.SIGINT, signal.SIGTERM):
signal.signal(stop_signal, lambda _signum, _frame: executor.stop())
executor.run() # returns after the message in progress, once a signal arrives
The stop request is permanent: calling run() on a stopped Executor returns immediately. Create a
new instance to consume again.
Give your supervisor's shutdown timeout (for example Kubernetes' terminationGracePeriodSeconds) at
least the longest execute() plus a few seconds; otherwise it kills the process in the middle of a
message.
Other ways run() ends
Without a stop request, run() ends only by raising:
- a failure, as described above;
RuntimeErrorif RabbitMQ cancels the consumer, which happens when the queue is deleted while the Executor runs. The Executor does not recreate the queue; under the permanent-topology model this is an error for an operator to investigate;- a
BaseExceptionsuch asSystemExitorKeyboardInterrupt, for example Python's default reaction to Ctrl+C when no signal handler callsstop(). This is a hard stop: the exception passes through unchanged, even in the middle ofexecute(), the connection is closed, and RabbitMQ requeues the message being processed, which is then executed again.
Status and timestamp
An Executor reports its lifecycle status, for example to a health check or a monitoring thread:
get_status() |
Meaning |
|---|---|
"RUNNING" |
Set when the Executor is created; stays while run() consumes, until stop() is called. |
"STOPPING" |
stop() was called; run() finishes the message in progress, then returns. |
"STOPPED" |
run() returned after a graceful stop. |
"CRASHED" |
run() ended by raising an exception: a failure or a hard stop. |
get_status() returns a plain string. The values are also available as the ExecutorStatus
string enum, so executor.get_status() == ExecutorStatus.STOPPED works too. "STOPPED" and
"CRASHED" are final: a later stop() does not change them. Calling run() again after
"CRASHED", without a stop request, makes the Executor "RUNNING" again.
get_timestamp() returns when the status last changed or was last read by get_status(), as an
ISO 8601 string in UTC, for example "2026-10-01T17:05:42.123456+00:00". Calling get_timestamp()
does not update it, so a monitor that calls get_status() regularly can tell when it last checked.
status = executor.get_status() # "RUNNING"; also updates the timestamp
checked_at = executor.get_timestamp() # e.g. "2026-10-01T17:05:42.123456+00:00"
Both methods are safe to call from any thread while run() is running, and neither uses the
RabbitMQ connection.
Long-running execute()
RabbitMQ uses heartbeats to detect dead connections, and pika can answer them only between messages,
never while execute() runs. If one execute() call lasts longer than the connection's heartbeat
timeout, RabbitMQ closes the connection and requeues the message while it is still being processed.
The work is wasted and the message is executed again, so an execute() that always takes that long
never completes. An Executor that publishes results discards them and reconnects; a terminator's
acknowledgement fails on the closed connection, which stops it.
Only the application knows how long its processing takes, so each Executor type sets its own
timeout with heartbeat_seconds, in seconds. It defaults to 600 (10 minutes), sized for LLM calls
and heavy computation, and RabbitMQ uses the requested value even when its own default is lower.
Choose a value comfortably above the longest execute() you expect for that Executor type:
executor = ClassifyingExecutor(
config,
source_queue="classifier",
source_exchange="items.inbox",
dead_letter_exchange="classifier.dlx",
dead_letter_queue="classifier.dlq",
destination_exchange="items.classified",
heartbeat_seconds=1800, # this classifier can take up to 20 minutes per item
)
Consider these when choosing the value:
- A larger value detects dead connections later. If an Executor's host or network disappears
without closing the connection, RabbitMQ redelivers its in-flight message only after up to
heartbeat_seconds. - RabbitMQ has a separate limit on unacknowledged messages. The broker's
consumer_timeout(30 minutes by default) closes the channel of a consumer that holds a message unacknowledged for longer, and requeues the message. Ifexecute()can take longer than that, raiseconsumer_timeouton the broker as well;heartbeat_secondsalone is not enough. - AMQP limits the value to 1–65535 seconds. Injectors keep the broker's default: they hold a connection only while publishing one message, so heartbeats do not limit them.
Scaling an Executor
To process a queue faster, run several instances of the same Executor. Give every instance the
same configuration (source_queue, source_exchange, dead_letter_exchange, dead_letter_queue,
destination_exchange, queue_type, max_processing_attempts, and heartbeat_seconds), and
start them as independent processes:
┌──► classifier instance 1 ──┐
items.inbox ──► classifier (queue) ───┼──► classifier instance 2 ──┼──► items.classified
└──► classifier instance 3 ──┘
- All instances declare the same queue, binding, and dead-letter route; RabbitMQ treats identical
declarations as no-ops. An instance configured differently, for example with another
dead_letter_exchangeorqueue_type, fails to connect with406 PRECONDITION_FAILED. - RabbitMQ delivers each message to one instance only. With the prefetch count of 1, each instance holds only the message it is processing, so the next message goes to whichever instance is free.
- Each instance acknowledges, requeues, and dead-letters its own deliveries; no coordination is needed. A requeued message may be delivered to any instance.
- Messages are processed in parallel, so they complete, and their results are published, in no particular order.
This is different from fan-out: Executors with their own queues bound to the same exchange each receive every message.
Things to plan for when running several instances:
- The same message can be executed by two instances at the same time. When RabbitMQ redelivers
a message, for example after a lost connection, another instance can receive it while the first is
still running
execute(). Results are still published only by the instance whose connection is intact, but side effects insideexecute(), such as database writes, can happen twice concurrently. The idempotency that pykicak requires must therefore be enforced by the database (see Requirement: idempotent processing). - An
execute()longer thanheartbeat_secondscauses such redeliveries. RabbitMQ closes the connection and redelivers the message while it is still being processed (see Long-runningexecute()). With several instances, another instance picks it up immediately. - An unexpected error stops only the instance that hit it. Each instance dead-letters the message and stops on its own, so a bug that fails every message dead-letters one message per instance before all have stopped.
Delivery guarantees
Every channel is in publisher-confirm mode, and every message is published as mandatory and
persistent (delivery_mode=2), with the content type application/json and its message_id
as the AMQP message_id property. A publish
returns only after RabbitMQ has taken responsibility for the message: for persistent messages in
durable queues, after writing it to disk. RabbitMQ refusing a message raises one of these errors,
which are not retried:
pika.exceptions.UnroutableError: no queue is bound to the exchange, so RabbitMQ would have discarded the message. For an Injector this means the first Executor's topology is not installed.pika.exceptions.NackError: RabbitMQ could not take responsibility for the message.
An Injector raises InjectionError to its caller, with one of these errors as its __cause__. An
Executor requeues the input message and stops (see
Error handling and dead-lettering), so no message is lost
between two stages of a pipeline.
A mandatory message counts as routed if at least one queue is bound to the exchange. A fanout exchange whose other bound queues are missing is not detected.
Retries and logging
Injectors retry transient RabbitMQ connection and publish failures up to three attempts, with
1- and 2-second delays between attempts; each retry publishes the message again on a new
connection, and after the third failed attempt the error is raised. Executors reconnect indefinitely after transient failures, using exponential full jitter
with a 0.5-second initial delay and a 30-second maximum, but never retry a result publish (see
Error handling and dead-lettering). Authentication and
access-denied failures are not retried.
Application processing is retried only for TransientProcessingError (see
Error handling and dead-lettering). Message acknowledgements
are not retried; a failed connection can cause an unacknowledged message to be delivered again by
RabbitMQ.
Publishing is at-least-once: if the connection is lost before RabbitMQ's confirmation arrives, RabbitMQ may already have accepted the message. The Injector then publishes it again, and the Executor executes the redelivered input again. pykicak does not provide exactly-once delivery, which is why it requires idempotent agents (see Requirement: idempotent processing).
The library emits standard Python logging records through module loggers. Applications configure handlers and levels, for example:
import logging
logging.basicConfig(level=logging.INFO)
Public API
pykicak exports ExecutorStatus, InjectionError, KicakConfig, KicakConfigError,
KicakExecutorAbstract, KicakInjectorAbstract, KicakMessage, MalformedMessageError,
QueueType, TransientProcessingError, and __version__. Build agents by subclassing
KicakInjectorAbstract or KicakExecutorAbstract; their shared base class is internal.
Proof of concept
examples/poc/ in the repository contains a runnable example (examples are not part of the
installed package): an injector that publishes a heartbeat message, an executor that publishes it
to a fan-out exchange, and two terminator executors that each declare their own queue and bind it
to that exchange. Each executor also declares a dead-letter
exchange and queue for its input queue (for example poc.heartbeat.source.dlx →
poc.heartbeat.source.dlq). Start all three executors (at least once) before running the
injector: until the fan-out executor has declared its queue, the injector fails with
UnroutableError, and until a terminator has declared its queue, the fan-out executor requeues the
message and stops. The queue/exchange topology is defined once in examples/poc/messaging/
(queues.py, exchanges.py, bindings.py) and imported by every agent — none of it lives in
.kicak.
Each process reads its .kicak file (connection details only) from the path in the
KICAK_CONFIG_PATH environment variable (defaulting to .kicak). The executor's role is
selected via POC_EXECUTOR_ROLE (fanout, terminator1, or terminator2). Ctrl+C or SIGTERM
stops an executor gracefully through stop(). Run the commands from the repository root:
KICAK_CONFIG_PATH=.kicak.injector python -m examples.poc.injector_agent
KICAK_CONFIG_PATH=.kicak.executor POC_EXECUTOR_ROLE=fanout python -m examples.poc.executor_agent
KICAK_CONFIG_PATH=.kicak.terminator1 POC_EXECUTOR_ROLE=terminator1 python -m examples.poc.executor_agent
KICAK_CONFIG_PATH=.kicak.terminator2 POC_EXECUTOR_ROLE=terminator2 python -m examples.poc.executor_agent
Development
Install the package in editable mode with the development tools (pytest, ruff, mypy, build, and twine), then run the test suite and the checks:
pip install -e . --group dev # pip 25.1 or newer
pytest
ruff check .
mypy
This excludes integration tests, which require a live RabbitMQ broker (e.g.
docker run -p 5672:5672 rabbitmq). They connect with the .kicak file in the working directory,
or the one whose path is in the KICAK_CONFIG_PATH environment variable; with the container above,
a copy of .kicak.example works as it is. Run them explicitly with:
pytest -m integration
License
pykicak is released under the MIT License.
Metadata
Release files for pykicak 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 | |
|---|---|---|---|
| pykicak-0.1.0.tar.gz | 53.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pykicak-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 87.7 kB
Release files / pykicak-0.1.0.tar.gz
| Download URL | pykicak-0.1.0.tar.gz |
|---|---|
| Size | 53.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6eb308db91e4f96c9379578b0f9f40239725ca956976cf6df52dc6b09cbd4e79
|
|
BLAKE2b-256 checksum How to use checksums |
f80f390047576ca89030b5e365961613f9c537d82bbf384e0fd0fc554032c0ec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / pykicak-0.1.0-py3-none-any.whl
| Download URL | pykicak-0.1.0-py3-none-any.whl |
|---|---|
| Size | 33.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7ed2ea64877a0693e8e7210f365ca967961961cb88787cdac2c448a13b854baa
|
|
BLAKE2b-256 checksum How to use checksums |
031f1f4c8c10e2afa5719b6261733a3edc5861e2a9c5db04f2f75b148b0fd97b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|