idemkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. idemkit/__init__.py +101 -0
  2. idemkit/_version.py +1 -0
  3. idemkit/adapters/__init__.py +10 -0
  4. idemkit/adapters/ai.py +723 -0
  5. idemkit/adapters/asgi.py +533 -0
  6. idemkit/adapters/queue.py +413 -0
  7. idemkit/adapters/route.py +273 -0
  8. idemkit/adapters/wsgi.py +393 -0
  9. idemkit/backends/__init__.py +30 -0
  10. idemkit/backends/base.py +116 -0
  11. idemkit/backends/dynamodb.py +489 -0
  12. idemkit/backends/memory.py +325 -0
  13. idemkit/backends/mongo.py +395 -0
  14. idemkit/backends/postgres.py +682 -0
  15. idemkit/backends/redis.py +547 -0
  16. idemkit/cli.py +165 -0
  17. idemkit/conformance/__init__.py +25 -0
  18. idemkit/conformance/backend.py +257 -0
  19. idemkit/conformance/report.py +54 -0
  20. idemkit/contrib/__init__.py +30 -0
  21. idemkit/contrib/drf.py +181 -0
  22. idemkit/contrib/fastapi.py +141 -0
  23. idemkit/contrib/kafka.py +96 -0
  24. idemkit/contrib/logging.py +61 -0
  25. idemkit/contrib/mcp.py +113 -0
  26. idemkit/contrib/prometheus.py +79 -0
  27. idemkit/contrib/pubsub.py +98 -0
  28. idemkit/contrib/rabbitmq.py +138 -0
  29. idemkit/contrib/reconciliation.py +86 -0
  30. idemkit/contrib/sqs.py +117 -0
  31. idemkit/core/__init__.py +36 -0
  32. idemkit/core/codecs.py +229 -0
  33. idemkit/core/config.py +255 -0
  34. idemkit/core/engine.py +331 -0
  35. idemkit/core/events.py +63 -0
  36. idemkit/core/exception_cache.py +88 -0
  37. idemkit/core/exceptions.py +79 -0
  38. idemkit/core/fingerprint.py +153 -0
  39. idemkit/core/policy.py +143 -0
  40. idemkit/core/runner.py +664 -0
  41. idemkit/core/state.py +95 -0
  42. idemkit/core/sync_bridge.py +115 -0
  43. idemkit/problem_details.py +119 -0
  44. idemkit/py.typed +0 -0
  45. idemkit-0.1.0.dist-info/METADATA +446 -0
  46. idemkit-0.1.0.dist-info/RECORD +48 -0
  47. idemkit-0.1.0.dist-info/WHEEL +4 -0
  48. idemkit-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,98 @@
1
+ """Google Cloud Pub/Sub glue for idemkit's queue surface.
2
+
3
+ Pub/Sub is at-least-once: if an ack doesn't reach the server before the ack
4
+ deadline (or you nack), the message is redelivered. Even with exactly-once
5
+ delivery enabled it only dedups *subscriber-side* redeliveries — a publisher that
6
+ retries a lost publish produces a new ``message_id``, so a duplicate can still
7
+ reach you. :func:`pubsub_consumer` presets an
8
+ :class:`~idemkit.IdempotentConsumer` so the side effect runs once per message.
9
+
10
+ Two Pub/Sub specifics:
11
+
12
+ * **Dedup id = ``message_id``.** Stable across subscriber redeliveries of the same
13
+ message. It does *not* cover publisher-side duplicates (those carry different
14
+ ids); if your producer can double-publish, dedup on a business id in the payload
15
+ instead via a custom ``QueueConfig.dedup_id``.
16
+ * **Visibility-timeout analogue = the ack deadline.** The lease is derived from it
17
+ (``ack_deadline_seconds``); for slow handlers, extend the deadline with the
18
+ client's lease-management or raise it on the subscription.
19
+
20
+ Streaming pull runs your callback in a background thread per message. Wrap it with
21
+ :func:`pubsub_callback`: it dispatches through idemkit and acks on ``ACK`` / nacks
22
+ on ``RETRY``.
23
+
24
+ Example (google-cloud-pubsub)::
25
+
26
+ from google.cloud import pubsub_v1
27
+ from idemkit import RedisBackend
28
+ from idemkit.contrib.pubsub import pubsub_consumer, pubsub_callback
29
+
30
+ consumer = pubsub_consumer(
31
+ backend=RedisBackend.from_url("redis://localhost:6379"),
32
+ ack_deadline_seconds=60,
33
+ )
34
+
35
+
36
+ @consumer.handle
37
+ def process(message) -> None:
38
+ charge_customer(message.data) # runs once per message_id
39
+
40
+
41
+ subscriber = pubsub_v1.SubscriberClient()
42
+ subscriber.subscribe(subscription_path, callback=pubsub_callback(consumer))
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import dataclasses
48
+ from collections.abc import Callable
49
+ from typing import Any
50
+
51
+ from idemkit.adapters.queue import ConsumerAction, IdempotentConsumer
52
+ from idemkit.backends.base import IdempotencyBackend
53
+ from idemkit.core.policy import QueueConfig
54
+
55
+
56
+ def pubsub_dedup_id(message: Any) -> str:
57
+ """The Pub/Sub ``message_id`` (the dedup id)."""
58
+ return str(message.message_id)
59
+
60
+
61
+ def pubsub_consumer(
62
+ *,
63
+ backend: IdempotencyBackend,
64
+ ack_deadline_seconds: float = 60.0,
65
+ config: QueueConfig | None = None,
66
+ ) -> IdempotentConsumer:
67
+ """Build an :class:`~idemkit.IdempotentConsumer` wired for Pub/Sub messages.
68
+
69
+ Presets the dedup id (``message_id``) and the lease (from the ack deadline).
70
+ Pass a :class:`~idemkit.QueueConfig` for behaviour (``max_attempts``,
71
+ ``on_exhausted``, ``scope`` per subscription, ...).
72
+ """
73
+ cfg = config or QueueConfig()
74
+ cfg = dataclasses.replace(
75
+ cfg,
76
+ dedup_id=pubsub_dedup_id,
77
+ visibility_timeout_seconds=ack_deadline_seconds,
78
+ )
79
+ return IdempotentConsumer(backend=backend, config=cfg)
80
+
81
+
82
+ def pubsub_callback(consumer: IdempotentConsumer) -> Callable[[Any], None]:
83
+ """Wrap ``consumer`` as a streaming-pull callback that acks/nacks the message.
84
+
85
+ Returns a function you pass to ``subscriber.subscribe(..., callback=...)``. It
86
+ dispatches the message through idemkit (``dispatch_sync``, since the callback
87
+ runs on the client's thread) and calls ``message.ack()`` on ``ACK`` or
88
+ ``message.nack()`` on ``RETRY`` (redeliver after the ack deadline).
89
+ """
90
+
91
+ def _callback(message: Any) -> None:
92
+ result = consumer.dispatch_sync(message)
93
+ if result.action is ConsumerAction.ACK:
94
+ message.ack()
95
+ else: # RETRY
96
+ message.nack()
97
+
98
+ return _callback
@@ -0,0 +1,138 @@
1
+ """RabbitMQ (pika) glue for idemkit's queue surface.
2
+
3
+ RabbitMQ is at-least-once under manual acks: an unacked delivery is requeued when
4
+ the channel or connection drops, and you can requeue explicitly with ``basic_nack``.
5
+ So a consumer sees the same message again after a network blip, a crash, or a
6
+ transient failure. :func:`rabbitmq_consumer` presets an
7
+ :class:`~idemkit.IdempotentConsumer` so the side effect runs once per message even
8
+ across those redeliveries.
9
+
10
+ Two things are different from SQS/Kafka, and worth stating plainly:
11
+
12
+ * **There is no dedup id built in.** RabbitMQ does not stamp a stable per-message
13
+ id for you. The clean choice is the AMQP ``message_id`` property, which the
14
+ *publisher* must set (a UUID, or your own business id). If your producer doesn't
15
+ set one, dedup has nothing to key on — see :class:`RabbitMessage`.
16
+ * **There is no visibility timeout.** An unacked message is not redelivered on a
17
+ timer; it comes back only on nack/requeue or a channel/connection loss. So the
18
+ lease here is just how long idemkit holds the claim before assuming a crash — you
19
+ pick it (``lease_seconds``), it isn't derived from the broker.
20
+
21
+ The ``redelivered`` flag RabbitMQ sets on a re-delivery is carried through on
22
+ :attr:`RabbitMessage.redelivered` if you want to log or branch on it.
23
+
24
+ Example (pika)::
25
+
26
+ import pika
27
+ from idemkit import RedisBackend
28
+ from idemkit.contrib.rabbitmq import rabbitmq_consumer, run_forever
29
+
30
+ consumer = rabbitmq_consumer(
31
+ backend=RedisBackend.from_url("redis://localhost:6379"),
32
+ lease_seconds=300,
33
+ )
34
+
35
+
36
+ @consumer.handle
37
+ def process(msg) -> None:
38
+ charge_customer(msg.body) # runs once per message_id
39
+
40
+
41
+ conn = pika.BlockingConnection(pika.ConnectionParameters("localhost"))
42
+ run_forever(consumer, channel=conn.channel(), queue="charges", lease_seconds=300)
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import dataclasses
48
+ from collections.abc import Callable
49
+ from typing import Any
50
+
51
+ from idemkit.adapters.queue import ConsumerAction, IdempotentConsumer
52
+ from idemkit.backends.base import IdempotencyBackend
53
+ from idemkit.core.policy import QueueConfig
54
+
55
+
56
+ @dataclasses.dataclass(frozen=True)
57
+ class RabbitMessage:
58
+ """One RabbitMQ delivery, in the shape idemkit dedupes on.
59
+
60
+ ``message_id`` is the AMQP ``message_id`` property the publisher set; it is the
61
+ dedup id. ``run_forever`` builds this for you from pika's delivery; if you drive
62
+ the consumer yourself, construct it from ``properties.message_id`` and ``body``.
63
+ """
64
+
65
+ message_id: str
66
+ body: bytes
67
+ redelivered: bool = False
68
+ delivery_tag: int | None = None
69
+
70
+
71
+ def rabbitmq_dedup_id(message: RabbitMessage) -> str:
72
+ """The message's AMQP ``message_id`` (the dedup id)."""
73
+ return message.message_id
74
+
75
+
76
+ def rabbitmq_consumer(
77
+ *,
78
+ backend: IdempotencyBackend,
79
+ lease_seconds: float = 300.0,
80
+ config: QueueConfig | None = None,
81
+ ) -> IdempotentConsumer:
82
+ """Build an :class:`~idemkit.IdempotentConsumer` wired for RabbitMQ deliveries.
83
+
84
+ Presets the dedup id (the AMQP ``message_id``) and the lease (``lease_seconds``,
85
+ since RabbitMQ has no visibility timeout to derive it from). Pass a
86
+ :class:`~idemkit.QueueConfig` for behaviour (``max_attempts``, ``on_exhausted``,
87
+ ``scope`` per queue, ...).
88
+ """
89
+ cfg = config or QueueConfig()
90
+ cfg = dataclasses.replace(
91
+ cfg,
92
+ dedup_id=rabbitmq_dedup_id,
93
+ visibility_timeout_seconds=lease_seconds,
94
+ )
95
+ return IdempotentConsumer(backend=backend, config=cfg)
96
+
97
+
98
+ def run_forever(
99
+ consumer: IdempotentConsumer,
100
+ *,
101
+ channel: Any,
102
+ queue: str,
103
+ lease_seconds: float = 300.0,
104
+ prefetch: int = 10,
105
+ stop: Callable[[], bool] | None = None,
106
+ ) -> None:
107
+ """Pull from ``queue`` and dispatch each delivery, acking on ``ACK``.
108
+
109
+ A blocking loop for a synchronous worker (it uses ``dispatch_sync`` and pika's
110
+ ``basic_get``). On ``ACK`` the delivery is acked; on ``RETRY`` it is nacked with
111
+ ``requeue=True`` so RabbitMQ redelivers it. The publisher MUST set the AMQP
112
+ ``message_id`` property; a delivery without one raises ``ValueError`` rather than
113
+ silently skipping dedup. Pass ``stop=lambda: flag`` for graceful shutdown.
114
+ """
115
+ channel.basic_qos(prefetch_count=prefetch)
116
+ while stop is None or not stop():
117
+ method, properties, body = channel.basic_get(queue=queue, auto_ack=False)
118
+ if method is None: # queue empty
119
+ continue
120
+ message_id = getattr(properties, "message_id", None)
121
+ if not message_id:
122
+ channel.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
123
+ raise ValueError(
124
+ "idemkit: RabbitMQ delivery has no message_id property to dedup on. "
125
+ "Publish with a message_id (a UUID or your business id), or key on a "
126
+ "header via a custom QueueConfig.dedup_id."
127
+ )
128
+ message = RabbitMessage(
129
+ message_id=message_id,
130
+ body=body,
131
+ redelivered=bool(getattr(method, "redelivered", False)),
132
+ delivery_tag=method.delivery_tag,
133
+ )
134
+ result = consumer.dispatch_sync(message)
135
+ if result.action is ConsumerAction.ACK:
136
+ channel.basic_ack(delivery_tag=method.delivery_tag)
137
+ else: # RETRY: leave it for redelivery
138
+ channel.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
@@ -0,0 +1,86 @@
1
+ """Turn idemkit's uncertain-completion events into a reconciliation signal.
2
+
3
+ Most of the time a key completes cleanly and there is nothing to reconcile. A few
4
+ decisions mean something else happened: the side effect ran, but idemkit could not
5
+ record its result, or ran without protection during a storage outage. In those
6
+ cases the record and reality may disagree, and only the downstream system (your
7
+ payment provider, your mailer) knows the truth.
8
+
9
+ idemkit cannot ask the provider for you. It does not know your business ids and it
10
+ holds only a hash of the key. What it can do is tell you *which* keys landed in the
11
+ uncertain zone, so your own reconciliation can go and check them. That is the
12
+ fourth assumption from the design guide (docs/the-four-assumptions.md): the boundary
13
+ between your record and a system you do not control.
14
+
15
+ Wire it like any other event handler::
16
+
17
+ from idemkit import MethodConfig, idempotent
18
+ from idemkit.contrib.reconciliation import reconciliation_handler
19
+
20
+
21
+ def needs_check(event):
22
+ # Correlate back to a business operation via your own pending record,
23
+ # or trust the key you passed downstream, then ask the provider.
24
+ reconcile_queue.put(event.effective_key)
25
+
26
+
27
+ @idempotent(
28
+ backend=backend,
29
+ config=MethodConfig(
30
+ key_fields=["order_id"],
31
+ event_handlers=(reconciliation_handler(needs_check),),
32
+ ),
33
+ )
34
+ async def charge(*, order_id): ...
35
+
36
+ The handler fires only for the decisions below. Everything else (a clean run, a
37
+ replay, a rejected payload) is left alone, so a quiet queue means nothing to do.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from idemkit.core.events import EventHandler, IdempotencyEvent
43
+ from idemkit.core.state import Decision
44
+
45
+ #: The decisions where a side effect may have fired without a recorded result, so
46
+ #: the record and the downstream system can disagree. Each is emitted at most once
47
+ #: per operation; see spec §4.15 and docs/the-four-assumptions.md.
48
+ NEEDS_RECONCILIATION: frozenset[Decision] = frozenset(
49
+ {
50
+ # The side effect ran, but writing its result failed. A retry re-runs it.
51
+ Decision.COMPLETE_FAILED,
52
+ # Our lease was reclaimed mid-run; our completion is fenced, but the side
53
+ # effect may already have fired before we lost the key.
54
+ Decision.LEASE_RECLAIMED_LOSS,
55
+ # The lease lapsed and the handler was cancelled part-way.
56
+ Decision.LEASE_LOST,
57
+ # fail_open ran the operation without dedup during a storage outage.
58
+ Decision.RAN_UNPROTECTED,
59
+ }
60
+ )
61
+
62
+ # Decision.CORRUPT_RECORD is left out on purpose. It fires when a stored record cannot be
63
+ # deserialized, which is a genuine duplicate-risk case, but a codec or schema change can
64
+ # emit it in bulk for records that are only unreadable, not wrong. Reconcile those on
65
+ # their own track so a migration does not flood this signal.
66
+
67
+
68
+ def reconciliation_handler(callback: EventHandler) -> EventHandler:
69
+ """Build an event handler that calls ``callback`` only for uncertain completions.
70
+
71
+ ``callback`` receives the :class:`~idemkit.IdempotencyEvent` for each decision in
72
+ :data:`NEEDS_RECONCILIATION` and nothing else. Use it to enqueue the key for a
73
+ reconciliation sweep, page an operator, or bump a metric. The event carries the
74
+ hashed ``effective_key`` (safe to log), the ``decision``, and the ``surface``; it
75
+ does not carry your business id, so correlate through your own pending record or
76
+ the key you passed downstream.
77
+
78
+ Like every event handler, a raised exception here is caught and suppressed by the
79
+ emitter, so a slow or broken sink never touches the request path.
80
+ """
81
+
82
+ def handle(event: IdempotencyEvent) -> None:
83
+ if event.decision in NEEDS_RECONCILIATION:
84
+ callback(event)
85
+
86
+ return handle
idemkit/contrib/sqs.py ADDED
@@ -0,0 +1,117 @@
1
+ """Amazon SQS glue for idemkit's queue surface.
2
+
3
+ SQS is at-least-once: it redelivers a message whenever a consumer doesn't delete
4
+ it within the visibility timeout. :func:`sqs_consumer` presets an
5
+ :class:`~idemkit.IdempotentConsumer` for the standard boto3 message shape so the
6
+ side effect runs once per SQS ``MessageId`` even across redeliveries, concurrent
7
+ consumers, and crashes.
8
+
9
+ Two SQS-specific defaults it gets right for you:
10
+
11
+ * **Dedup id = ``MessageId``.** Stable across redeliveries of the same message.
12
+ * **Attempt count = ``ApproximateReceiveCount``.** SQS's own receive count, so
13
+ ``max_attempts`` is enforced durably (no in-process counter). Request it when
14
+ you receive: ``AttributeNames=["ApproximateReceiveCount"]`` — :func:`run_forever`
15
+ does this for you.
16
+
17
+ Ack = *delete the message*. :func:`run_forever` deletes on ``ACK`` and leaves the
18
+ message for redelivery on ``RETRY``.
19
+
20
+ Example::
21
+
22
+ import boto3
23
+ from idemkit import RedisBackend
24
+ from idemkit.contrib.sqs import sqs_consumer, run_forever
25
+
26
+ sqs = boto3.client("sqs")
27
+ consumer = sqs_consumer(
28
+ backend=RedisBackend.from_url("redis://localhost:6379"),
29
+ visibility_timeout_seconds=30,
30
+ config=QueueConfig(
31
+ max_attempts=5,
32
+ on_exhausted=lambda msg, exc: sqs.send_message(QueueUrl=DLQ, MessageBody=msg["Body"]),
33
+ ),
34
+ )
35
+
36
+
37
+ @consumer.handle
38
+ def process(msg) -> None:
39
+ charge_customer(msg["Body"]) # runs once per MessageId
40
+
41
+
42
+ run_forever(consumer, sqs_client=sqs, queue_url=QUEUE_URL, visibility_timeout=30)
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import dataclasses
48
+ from collections.abc import Callable
49
+ from typing import Any
50
+
51
+ from idemkit.adapters.queue import ConsumerAction, IdempotentConsumer
52
+ from idemkit.backends.base import IdempotencyBackend
53
+ from idemkit.core.policy import QueueConfig
54
+
55
+
56
+ def sqs_receive_count(message: dict[str, Any]) -> int | None:
57
+ """Read SQS ``ApproximateReceiveCount`` from a boto3 message dict."""
58
+ attrs = message.get("Attributes") or {}
59
+ value = attrs.get("ApproximateReceiveCount")
60
+ return int(value) if value is not None else None
61
+
62
+
63
+ def sqs_consumer(
64
+ *,
65
+ backend: IdempotencyBackend,
66
+ visibility_timeout_seconds: float,
67
+ config: QueueConfig | None = None,
68
+ ) -> IdempotentConsumer:
69
+ """Build an :class:`~idemkit.IdempotentConsumer` wired for boto3 SQS messages.
70
+
71
+ SQS presets the dedup id (``MessageId``) and the attempt count
72
+ (``ApproximateReceiveCount``). Pass a :class:`~idemkit.QueueConfig` for
73
+ behaviour (``max_attempts``, ``on_exhausted``, ``scope`` per queue, ...).
74
+ """
75
+ cfg = config or QueueConfig()
76
+ presets: dict[str, Any] = {
77
+ "dedup_id": lambda m: m["MessageId"],
78
+ "visibility_timeout_seconds": visibility_timeout_seconds,
79
+ }
80
+ if cfg.receive_count is None:
81
+ presets["receive_count"] = sqs_receive_count
82
+ cfg = dataclasses.replace(cfg, **presets)
83
+ return IdempotentConsumer(backend=backend, config=cfg)
84
+
85
+
86
+ def run_forever(
87
+ consumer: IdempotentConsumer,
88
+ *,
89
+ sqs_client: Any,
90
+ queue_url: str,
91
+ visibility_timeout: int,
92
+ wait_time_seconds: int = 20,
93
+ max_messages: int = 10,
94
+ stop: Callable[[], bool] | None = None,
95
+ ) -> None:
96
+ """Poll SQS and dispatch each message, deleting it on ``ACK``.
97
+
98
+ A blocking loop for a synchronous worker (it uses ``dispatch_sync``). Long-polls
99
+ with ``wait_time_seconds`` and requests ``ApproximateReceiveCount`` so attempt
100
+ counting is durable. On ``ACK`` the message is deleted; on ``RETRY`` it is left
101
+ for redelivery (the visibility timeout lapses). Pass ``stop=lambda: flag`` to
102
+ break the loop for graceful shutdown.
103
+ """
104
+ while stop is None or not stop():
105
+ resp = sqs_client.receive_message(
106
+ QueueUrl=queue_url,
107
+ MaxNumberOfMessages=max_messages,
108
+ WaitTimeSeconds=wait_time_seconds,
109
+ VisibilityTimeout=visibility_timeout,
110
+ AttributeNames=["ApproximateReceiveCount"],
111
+ )
112
+ for message in resp.get("Messages", []):
113
+ result = consumer.dispatch_sync(message)
114
+ if result.action is ConsumerAction.ACK:
115
+ sqs_client.delete_message(
116
+ QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"]
117
+ )
@@ -0,0 +1,36 @@
1
+ from idemkit.core.config import IdempotencyConfig
2
+ from idemkit.core.events import EventEmitter, IdempotencyEvent
3
+ from idemkit.core.exceptions import (
4
+ ConfigurationError,
5
+ IdempotencyError,
6
+ StorageError,
7
+ )
8
+ from idemkit.core.fingerprint import (
9
+ FINGERPRINT_VERSION,
10
+ compute_effective_key,
11
+ compute_fingerprint,
12
+ )
13
+ from idemkit.core.state import (
14
+ ClaimResult,
15
+ ClaimResultType,
16
+ Decision,
17
+ State,
18
+ StoredRecord,
19
+ )
20
+
21
+ __all__ = [
22
+ "FINGERPRINT_VERSION",
23
+ "ClaimResult",
24
+ "ClaimResultType",
25
+ "ConfigurationError",
26
+ "Decision",
27
+ "EventEmitter",
28
+ "IdempotencyConfig",
29
+ "IdempotencyError",
30
+ "IdempotencyEvent",
31
+ "State",
32
+ "StorageError",
33
+ "StoredRecord",
34
+ "compute_effective_key",
35
+ "compute_fingerprint",
36
+ ]