latch-idempotent 0.1.0__tar.gz
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.
- latch_idempotent-0.1.0/.github/workflows/ci.yml +28 -0
- latch_idempotent-0.1.0/.gitignore +15 -0
- latch_idempotent-0.1.0/CLAUDE.md +111 -0
- latch_idempotent-0.1.0/LICENSE +21 -0
- latch_idempotent-0.1.0/PKG-INFO +98 -0
- latch_idempotent-0.1.0/README.md +69 -0
- latch_idempotent-0.1.0/examples/basic_example.py +23 -0
- latch_idempotent-0.1.0/examples/openai_example.py +46 -0
- latch_idempotent-0.1.0/pyproject.toml +48 -0
- latch_idempotent-0.1.0/src/latch/__init__.py +14 -0
- latch_idempotent-0.1.0/src/latch/core.py +89 -0
- latch_idempotent-0.1.0/src/latch/exceptions.py +12 -0
- latch_idempotent-0.1.0/src/latch/stores/__init__.py +4 -0
- latch_idempotent-0.1.0/src/latch/stores/base.py +26 -0
- latch_idempotent-0.1.0/src/latch/stores/memory.py +42 -0
- latch_idempotent-0.1.0/tests/test_core.py +114 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- name: Install dependencies
|
|
20
|
+
run: |
|
|
21
|
+
python -m pip install --upgrade pip
|
|
22
|
+
pip install -e ".[dev]"
|
|
23
|
+
- name: Lint
|
|
24
|
+
run: ruff check src tests
|
|
25
|
+
- name: Type check
|
|
26
|
+
run: mypy src/latch
|
|
27
|
+
- name: Test
|
|
28
|
+
run: pytest -v
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# latch — Idempotency Middleware for LLM Agent Tool Calls
|
|
2
|
+
|
|
3
|
+
This file is the persistent context for Claude Code working in this repo. Read it fully before making changes. Keep it updated as phases complete.
|
|
4
|
+
|
|
5
|
+
## Mission
|
|
6
|
+
|
|
7
|
+
`latch` is a small, dependency-free Python library that makes LLM agent tool calls safe to retry. When an agent's tool call times out or errors, the agent doesn't know whether the underlying action actually completed — retrying blindly can double-charge a card, send a duplicate email, or create a duplicate order. `latch` prevents that: wrap a tool function with `@idempotent`, pass a unique `idempotency_key` per logical operation, and repeated calls with the same key return the cached result instead of re-executing.
|
|
8
|
+
|
|
9
|
+
## Why this exists (prior art — be honest about it)
|
|
10
|
+
|
|
11
|
+
This is not an unclaimed idea. Before building, we searched and found:
|
|
12
|
+
- **SagaLLM** (arXiv 2503.11951) — academic paper applying the Saga pattern to multi-agent LLM planning with compensation.
|
|
13
|
+
- **Robust Agent Compensation (RAC)** (ACM CAIS '26) — academic paper on teaching agents to compensate for failures.
|
|
14
|
+
- **ReliabilityBench** (arXiv 2601.06112) — a benchmark for agent reliability under stress, not a library.
|
|
15
|
+
- Multiple engineering blog posts (Zylos Research, MightyBot, Chanl, TianPan.co) describe idempotency-for-agents as a pattern, but none of them shipped a polished, adoptable, framework-agnostic OSS package for it.
|
|
16
|
+
|
|
17
|
+
**The gap we're actually filling:** nobody has shipped the practical, pip-installable version of this pattern. The contribution of v0.1 is execution and adoptability, not novelty of the underlying idea. Don't oversell originality in the README or paper — cite the above as prior art and position `latch` as "the missing implementation," not "the first to think of this."
|
|
18
|
+
|
|
19
|
+
## Scope
|
|
20
|
+
|
|
21
|
+
### v0.1 (current phase — idempotency only)
|
|
22
|
+
- `@idempotent` decorator for sync and async functions
|
|
23
|
+
- Pluggable `IdempotencyStore` interface
|
|
24
|
+
- `InMemoryStore` (default, thread-safe, TTL-based expiry)
|
|
25
|
+
- Explicit, required `idempotency_key` kwarg (caller/agent framework supplies it — `latch` does not guess or auto-hash args, because silent auto-keying can hide bugs)
|
|
26
|
+
- Tests for sync, async, cache-hit, cache-miss, missing-key error, and TTL expiry
|
|
27
|
+
- One usage example for a plain function and one for an OpenAI-style tool call
|
|
28
|
+
|
|
29
|
+
### Explicit non-goals for v0.1 (do not build yet)
|
|
30
|
+
- Circuit breaker (v0.2)
|
|
31
|
+
- Timeout/cancellation propagation (v0.2)
|
|
32
|
+
- Budget/cost guardrails (v0.2)
|
|
33
|
+
- Saga/compensation (v0.3)
|
|
34
|
+
- Framework adapters beyond a documented example (v0.3 — real adapter package)
|
|
35
|
+
- Redis store (stub the interface only; implement in v0.2 as an optional extra)
|
|
36
|
+
- Distributed tracing/observability (v0.4)
|
|
37
|
+
|
|
38
|
+
Resist scope creep. Ship v0.1 narrow and solid before touching v0.2.
|
|
39
|
+
|
|
40
|
+
## Roadmap (from the 90-day plan)
|
|
41
|
+
|
|
42
|
+
- [x] v0.1 — Idempotency core + in-memory store + tests + docs
|
|
43
|
+
- [ ] v0.2 — Circuit breaker, timeout/cancellation, budget guardrails, Redis store
|
|
44
|
+
- [ ] v0.3 — Saga/compensation pattern, real OpenAI + LangChain adapter modules
|
|
45
|
+
- [ ] v0.4 — Chaos-injection benchmark harness, example agents (before/after), tracing hooks
|
|
46
|
+
- [ ] v1.0 — Docs site, public launch, paper submitted to arXiv
|
|
47
|
+
|
|
48
|
+
Update the checkboxes above as phases land.
|
|
49
|
+
|
|
50
|
+
## API design (already decided — implement to this contract)
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from latch import idempotent, InMemoryStore
|
|
54
|
+
|
|
55
|
+
store = InMemoryStore()
|
|
56
|
+
|
|
57
|
+
@idempotent(store=store, ttl_seconds=86400)
|
|
58
|
+
def create_order(order_id: str, amount: float) -> dict:
|
|
59
|
+
# idempotency_key is consumed by the decorator, not passed to this function
|
|
60
|
+
...
|
|
61
|
+
return {"order_id": order_id, "status": "created"}
|
|
62
|
+
|
|
63
|
+
# Agent framework supplies a unique key per logical operation:
|
|
64
|
+
create_order(order_id="A1", amount=42.0, idempotency_key="agent-run-7-step-3")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Key behaviors:
|
|
68
|
+
- `idempotency_key` is a required keyword argument at call time. If missing, raise `IdempotencyKeyMissingError` — never silently generate one.
|
|
69
|
+
- On a cache hit within the TTL window, return the cached result without calling the wrapped function.
|
|
70
|
+
- On a cache miss, execute the function, store the result keyed by `idempotency_key`, and return it.
|
|
71
|
+
- Must work transparently on both `def` and `async def` functions.
|
|
72
|
+
- No required third-party dependencies for the core. Redis support is an optional extra (`pip install latch-idempotent[redis]`).
|
|
73
|
+
|
|
74
|
+
## Architecture principles
|
|
75
|
+
|
|
76
|
+
- Core has zero required dependencies — anyone can `pip install` it without dragging in redis/httpx/etc.
|
|
77
|
+
- `IdempotencyStore` is an abstract interface (`get`, `set`, `exists`) so storage backends are swappable.
|
|
78
|
+
- Every public function is type-hinted; run `mypy` clean.
|
|
79
|
+
- Every behavior has a test before being considered done — no untested code paths in `core.py`.
|
|
80
|
+
- Keep files small and single-purpose (`core.py`, `stores/memory.py`, `exceptions.py` — don't merge concerns).
|
|
81
|
+
|
|
82
|
+
## Conventions
|
|
83
|
+
|
|
84
|
+
- Format/lint with `ruff`; format with `black` (or ruff format).
|
|
85
|
+
- Conventional commit messages (`feat:`, `fix:`, `test:`, `docs:`, `chore:`).
|
|
86
|
+
- Every new feature: implementation + tests + README update in the same commit/PR.
|
|
87
|
+
- Run `pytest` before considering any task complete.
|
|
88
|
+
|
|
89
|
+
## Definition of done for v0.1
|
|
90
|
+
|
|
91
|
+
1. `pytest` passes with tests covering: sync dedup, async dedup, different-key re-execution, missing-key error, TTL expiry.
|
|
92
|
+
2. `mypy src/latch` is clean.
|
|
93
|
+
3. README has a working quickstart example that a stranger could copy-paste and run.
|
|
94
|
+
4. Package builds (`python -m build`) and is ready to publish to PyPI (actual publish is a manual step — don't automate credentials).
|
|
95
|
+
5. `examples/openai_example.py` shows the decorator wrapping an OpenAI-style tool function.
|
|
96
|
+
|
|
97
|
+
## Immediate next tasks (work through in order)
|
|
98
|
+
|
|
99
|
+
1. Verify the scaffolded code in `src/latch/` builds and `pytest` passes as-is.
|
|
100
|
+
2. Add TTL-expiry test (not yet covered by the scaffold — see `tests/test_core.py` TODO).
|
|
101
|
+
3. Write `examples/openai_example.py`.
|
|
102
|
+
4. Set up `ruff` + `mypy` config and run them clean.
|
|
103
|
+
5. Set up GitHub Actions CI (test + lint on push).
|
|
104
|
+
6. Once v0.1 is solid: write the launch README section, tag `v0.1.0`, publish to PyPI, post on GitHub.
|
|
105
|
+
7. Only then move to v0.2 (circuit breaker) — do not start v0.2 work before v0.1 is tagged and published.
|
|
106
|
+
|
|
107
|
+
## Non-negotiables
|
|
108
|
+
|
|
109
|
+
- Don't silently swallow errors — if the wrapped function raises, `latch` should propagate the exception and NOT cache a failed result.
|
|
110
|
+
- Don't auto-generate idempotency keys from function args by default — that's a footgun (subtly different args should sometimes get different keys, sometimes not, and only the caller knows which).
|
|
111
|
+
- Keep the public API tiny. Every new parameter on `idempotent()` should justify its complexity cost.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Venkata Sangaraju
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: latch-idempotent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Idempotency middleware for LLM agent tool calls — prevent duplicate side effects on retry.
|
|
5
|
+
Project-URL: Homepage, https://github.com/YOUR_USERNAME/latch
|
|
6
|
+
Project-URL: Issues, https://github.com/YOUR_USERNAME/latch/issues
|
|
7
|
+
Author: Venkata Sangaraju
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,idempotency,llm,reliability,tool-calling
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
26
|
+
Provides-Extra: redis
|
|
27
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# latch
|
|
31
|
+
|
|
32
|
+
Idempotency middleware for LLM agent tool calls. Prevents duplicate side effects (double-charged orders, duplicate emails, duplicate records) when an agent retries a tool call after a timeout or transient failure.
|
|
33
|
+
|
|
34
|
+
## The problem
|
|
35
|
+
|
|
36
|
+
An agent calls a tool. The call times out. The agent doesn't know if the underlying action completed before the timeout — it just knows it didn't get a response. Retrying is the only reasonable move, but if the tool isn't idempotent, retrying can execute the side effect twice.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install latch-idempotent
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from latch import idempotent
|
|
48
|
+
|
|
49
|
+
@idempotent()
|
|
50
|
+
def create_order(order_id: str, amount: float) -> dict:
|
|
51
|
+
# ... call your payment/order API ...
|
|
52
|
+
return {"order_id": order_id, "status": "created"}
|
|
53
|
+
|
|
54
|
+
# The agent framework supplies a unique key per logical operation.
|
|
55
|
+
result = create_order(order_id="A1", amount=42.0, idempotency_key="run-7-step-3")
|
|
56
|
+
|
|
57
|
+
# A retry with the same key returns the cached result instead of
|
|
58
|
+
# re-executing create_order — no duplicate order.
|
|
59
|
+
result_again = create_order(order_id="A1", amount=42.0, idempotency_key="run-7-step-3")
|
|
60
|
+
assert result == result_again
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Works the same way for `async def` tool functions.
|
|
64
|
+
|
|
65
|
+
## How it works
|
|
66
|
+
|
|
67
|
+
- `idempotency_key` is a required keyword argument — `latch` never guesses or auto-generates one. The caller (your agent framework or orchestration code) decides what constitutes "the same logical operation."
|
|
68
|
+
- On first call with a given key, the function executes and the result is cached.
|
|
69
|
+
- On any subsequent call with the same key, within the TTL window (default 24h), the cached result is returned and the function is not re-executed.
|
|
70
|
+
- If the wrapped function raises, nothing is cached — the exception propagates normally so retries can still happen.
|
|
71
|
+
|
|
72
|
+
## Storage backends
|
|
73
|
+
|
|
74
|
+
Default is an in-memory store (single-process, not persistent across restarts). For multi-process or production deployments, use a distributed store:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from latch import idempotent, InMemoryStore
|
|
78
|
+
|
|
79
|
+
store = InMemoryStore()
|
|
80
|
+
|
|
81
|
+
@idempotent(store=store, ttl_seconds=3600)
|
|
82
|
+
def send_email(to: str) -> dict:
|
|
83
|
+
...
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Redis backend is planned for v0.2 (`pip install latch-idempotent[redis]`).
|
|
87
|
+
|
|
88
|
+
## Status
|
|
89
|
+
|
|
90
|
+
v0.1 — idempotency only. See `CLAUDE.md` for the roadmap (circuit breaker, budget guardrails, saga/compensation, and framework adapters are planned for later versions).
|
|
91
|
+
|
|
92
|
+
## Prior art
|
|
93
|
+
|
|
94
|
+
This library exists alongside academic work on agent reliability — see [SagaLLM](https://arxiv.org/abs/2503.11951), [Robust Agent Compensation](https://arxiv.org/pdf/2605.03409), and [ReliabilityBench](https://arxiv.org/pdf/2601.06112). `latch`'s contribution is a small, adoptable, production-ready implementation of the idempotency pattern specifically — not a claim of inventing it.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# latch
|
|
2
|
+
|
|
3
|
+
Idempotency middleware for LLM agent tool calls. Prevents duplicate side effects (double-charged orders, duplicate emails, duplicate records) when an agent retries a tool call after a timeout or transient failure.
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
An agent calls a tool. The call times out. The agent doesn't know if the underlying action completed before the timeout — it just knows it didn't get a response. Retrying is the only reasonable move, but if the tool isn't idempotent, retrying can execute the side effect twice.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install latch-idempotent
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from latch import idempotent
|
|
19
|
+
|
|
20
|
+
@idempotent()
|
|
21
|
+
def create_order(order_id: str, amount: float) -> dict:
|
|
22
|
+
# ... call your payment/order API ...
|
|
23
|
+
return {"order_id": order_id, "status": "created"}
|
|
24
|
+
|
|
25
|
+
# The agent framework supplies a unique key per logical operation.
|
|
26
|
+
result = create_order(order_id="A1", amount=42.0, idempotency_key="run-7-step-3")
|
|
27
|
+
|
|
28
|
+
# A retry with the same key returns the cached result instead of
|
|
29
|
+
# re-executing create_order — no duplicate order.
|
|
30
|
+
result_again = create_order(order_id="A1", amount=42.0, idempotency_key="run-7-step-3")
|
|
31
|
+
assert result == result_again
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Works the same way for `async def` tool functions.
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
- `idempotency_key` is a required keyword argument — `latch` never guesses or auto-generates one. The caller (your agent framework or orchestration code) decides what constitutes "the same logical operation."
|
|
39
|
+
- On first call with a given key, the function executes and the result is cached.
|
|
40
|
+
- On any subsequent call with the same key, within the TTL window (default 24h), the cached result is returned and the function is not re-executed.
|
|
41
|
+
- If the wrapped function raises, nothing is cached — the exception propagates normally so retries can still happen.
|
|
42
|
+
|
|
43
|
+
## Storage backends
|
|
44
|
+
|
|
45
|
+
Default is an in-memory store (single-process, not persistent across restarts). For multi-process or production deployments, use a distributed store:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from latch import idempotent, InMemoryStore
|
|
49
|
+
|
|
50
|
+
store = InMemoryStore()
|
|
51
|
+
|
|
52
|
+
@idempotent(store=store, ttl_seconds=3600)
|
|
53
|
+
def send_email(to: str) -> dict:
|
|
54
|
+
...
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Redis backend is planned for v0.2 (`pip install latch-idempotent[redis]`).
|
|
58
|
+
|
|
59
|
+
## Status
|
|
60
|
+
|
|
61
|
+
v0.1 — idempotency only. See `CLAUDE.md` for the roadmap (circuit breaker, budget guardrails, saga/compensation, and framework adapters are planned for later versions).
|
|
62
|
+
|
|
63
|
+
## Prior art
|
|
64
|
+
|
|
65
|
+
This library exists alongside academic work on agent reliability — see [SagaLLM](https://arxiv.org/abs/2503.11951), [Robust Agent Compensation](https://arxiv.org/pdf/2605.03409), and [ReliabilityBench](https://arxiv.org/pdf/2601.06112). `latch`'s contribution is a small, adoptable, production-ready implementation of the idempotency pattern specifically — not a claim of inventing it.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Minimal usage example: an order-creation tool made retry-safe with latch."""
|
|
2
|
+
|
|
3
|
+
from latch import idempotent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@idempotent()
|
|
7
|
+
def create_order(order_id: str, amount: float) -> dict:
|
|
8
|
+
print(f"Charging {amount} for order {order_id}...") # simulates a real side effect
|
|
9
|
+
return {"order_id": order_id, "amount": amount, "status": "created"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
# First call executes and charges.
|
|
14
|
+
result = create_order(order_id="A1", amount=42.0, idempotency_key="run-7-step-3")
|
|
15
|
+
print("First call result:", result)
|
|
16
|
+
|
|
17
|
+
# Simulated retry after a timeout, same logical operation, same key.
|
|
18
|
+
# This does NOT charge again — it returns the cached result.
|
|
19
|
+
retry_result = create_order(order_id="A1", amount=42.0, idempotency_key="run-7-step-3")
|
|
20
|
+
print("Retry result:", retry_result)
|
|
21
|
+
|
|
22
|
+
assert result == retry_result
|
|
23
|
+
print("No duplicate charge on retry.")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Example: wrapping an OpenAI tool-calling function with latch.
|
|
2
|
+
|
|
3
|
+
This shows the shape of the integration; it doesn't require an actual
|
|
4
|
+
OpenAI API call to demonstrate the idempotency behavior. In a real agent
|
|
5
|
+
loop, `idempotency_key` would typically be derived from something like
|
|
6
|
+
`f"{run_id}:{step_id}"` and passed alongside the tool call arguments the
|
|
7
|
+
model returns.
|
|
8
|
+
|
|
9
|
+
TODO (v0.3): a real `latch.adapters.openai` module that auto-wraps a list
|
|
10
|
+
of tool functions and injects idempotency_key generation into the agent
|
|
11
|
+
loop, so callers don't have to thread it through manually.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from latch import idempotent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@idempotent()
|
|
18
|
+
def send_email(to: str, subject: str, body: str) -> dict:
|
|
19
|
+
print(f"Sending email to {to}: {subject}")
|
|
20
|
+
return {"to": to, "subject": subject, "status": "sent"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def handle_tool_call(tool_name: str, arguments: dict, run_id: str, step_id: str) -> dict:
|
|
24
|
+
"""Simulates how an agent loop would dispatch a model-requested tool call."""
|
|
25
|
+
idempotency_key = f"{run_id}:{step_id}"
|
|
26
|
+
|
|
27
|
+
if tool_name == "send_email":
|
|
28
|
+
return send_email(**arguments, idempotency_key=idempotency_key)
|
|
29
|
+
|
|
30
|
+
raise ValueError(f"Unknown tool: {tool_name}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
args = {"to": "user@example.com", "subject": "Welcome", "body": "Hi there!"}
|
|
35
|
+
|
|
36
|
+
# First attempt.
|
|
37
|
+
result = handle_tool_call("send_email", args, run_id="run-42", step_id="step-1")
|
|
38
|
+
print("First attempt:", result)
|
|
39
|
+
|
|
40
|
+
# Agent retries the same step after a timeout — same run_id/step_id,
|
|
41
|
+
# so the same idempotency_key is derived, and no duplicate email is sent.
|
|
42
|
+
retry = handle_tool_call("send_email", args, run_id="run-42", step_id="step-1")
|
|
43
|
+
print("Retry:", retry)
|
|
44
|
+
|
|
45
|
+
assert result == retry
|
|
46
|
+
print("No duplicate email sent on retry.")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "latch-idempotent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Idempotency middleware for LLM agent tool calls — prevent duplicate side effects on retry."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Venkata Sangaraju" }]
|
|
13
|
+
keywords = ["llm", "agents", "idempotency", "reliability", "tool-calling"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
redis = ["redis>=5.0"]
|
|
29
|
+
dev = ["pytest>=7.0", "pytest-asyncio>=0.23", "mypy>=1.8", "ruff>=0.4"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/YOUR_USERNAME/latch"
|
|
33
|
+
Issues = "https://github.com/YOUR_USERNAME/latch/issues"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/latch"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
asyncio_mode = "auto"
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
target-version = "py39"
|
|
45
|
+
|
|
46
|
+
[tool.mypy]
|
|
47
|
+
python_version = "3.10"
|
|
48
|
+
strict = true
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from latch.core import idempotent
|
|
2
|
+
from latch.exceptions import IdempotencyKeyMissingError, LatchError
|
|
3
|
+
from latch.stores.base import IdempotencyStore
|
|
4
|
+
from latch.stores.memory import InMemoryStore
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"idempotent",
|
|
8
|
+
"IdempotencyStore",
|
|
9
|
+
"InMemoryStore",
|
|
10
|
+
"LatchError",
|
|
11
|
+
"IdempotencyKeyMissingError",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
from typing import Any, Callable, Dict, Optional, TypeVar
|
|
4
|
+
|
|
5
|
+
from latch.exceptions import IdempotencyKeyMissingError
|
|
6
|
+
from latch.stores.base import IdempotencyStore
|
|
7
|
+
from latch.stores.memory import InMemoryStore
|
|
8
|
+
|
|
9
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
10
|
+
|
|
11
|
+
_DEFAULT_STORE: IdempotencyStore = InMemoryStore()
|
|
12
|
+
_DEFAULT_TTL_SECONDS = 24 * 60 * 60 # 24 hours
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def idempotent(
|
|
16
|
+
*,
|
|
17
|
+
store: Optional[IdempotencyStore] = None,
|
|
18
|
+
key_arg: str = "idempotency_key",
|
|
19
|
+
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
|
|
20
|
+
on_duplicate: Optional[Callable[[str, Any], None]] = None,
|
|
21
|
+
) -> Callable[[F], F]:
|
|
22
|
+
"""Make a tool function idempotent.
|
|
23
|
+
|
|
24
|
+
The decorated function must be called with a keyword argument matching
|
|
25
|
+
`key_arg` (default: "idempotency_key"). If a call with the same key has
|
|
26
|
+
already completed successfully within `ttl_seconds`, the cached result
|
|
27
|
+
is returned instead of re-executing the function.
|
|
28
|
+
|
|
29
|
+
Works transparently on both sync (`def`) and async (`async def`)
|
|
30
|
+
functions. The `key_arg` is consumed by the decorator and is not
|
|
31
|
+
passed through to the wrapped function.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
store: Idempotency storage backend. Defaults to a shared
|
|
35
|
+
process-wide in-memory store if not provided.
|
|
36
|
+
key_arg: Name of the keyword argument callers use to supply the
|
|
37
|
+
idempotency key.
|
|
38
|
+
ttl_seconds: How long a cached result remains valid.
|
|
39
|
+
on_duplicate: Optional callback invoked as `on_duplicate(key, cached_result)`
|
|
40
|
+
when a duplicate call is detected. Useful for logging/metrics.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
IdempotencyKeyMissingError: if the caller does not supply `key_arg`.
|
|
44
|
+
"""
|
|
45
|
+
active_store = store if store is not None else _DEFAULT_STORE
|
|
46
|
+
|
|
47
|
+
def decorator(func: F) -> F:
|
|
48
|
+
if inspect.iscoroutinefunction(func):
|
|
49
|
+
|
|
50
|
+
@functools.wraps(func)
|
|
51
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
52
|
+
key = _extract_key(kwargs, key_arg)
|
|
53
|
+
cached = active_store.get(key)
|
|
54
|
+
if cached is not None:
|
|
55
|
+
if on_duplicate is not None:
|
|
56
|
+
on_duplicate(key, cached)
|
|
57
|
+
return cached
|
|
58
|
+
result = await func(*args, **kwargs)
|
|
59
|
+
active_store.set(key, result, ttl_seconds)
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
return async_wrapper # type: ignore[return-value]
|
|
63
|
+
|
|
64
|
+
@functools.wraps(func)
|
|
65
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
66
|
+
key = _extract_key(kwargs, key_arg)
|
|
67
|
+
cached = active_store.get(key)
|
|
68
|
+
if cached is not None:
|
|
69
|
+
if on_duplicate is not None:
|
|
70
|
+
on_duplicate(key, cached)
|
|
71
|
+
return cached
|
|
72
|
+
result = func(*args, **kwargs)
|
|
73
|
+
active_store.set(key, result, ttl_seconds)
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
return sync_wrapper # type: ignore[return-value]
|
|
77
|
+
|
|
78
|
+
return decorator
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _extract_key(kwargs: Dict[str, Any], key_arg: str) -> str:
|
|
82
|
+
if key_arg not in kwargs or kwargs[key_arg] is None:
|
|
83
|
+
raise IdempotencyKeyMissingError(
|
|
84
|
+
f"Missing required idempotency key argument '{key_arg}'. "
|
|
85
|
+
f"Callers (typically the agent framework) must supply a unique "
|
|
86
|
+
f"key per logical operation so retries can be deduplicated."
|
|
87
|
+
)
|
|
88
|
+
# Pop so the wrapped function's own signature stays clean.
|
|
89
|
+
return str(kwargs.pop(key_arg))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class LatchError(Exception):
|
|
2
|
+
"""Base exception for all latch errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class IdempotencyKeyMissingError(LatchError):
|
|
6
|
+
"""Raised when a decorated function is called without a required
|
|
7
|
+
`idempotency_key` keyword argument.
|
|
8
|
+
|
|
9
|
+
latch never generates idempotency keys automatically — the caller
|
|
10
|
+
(typically the agent framework or orchestration layer) must supply
|
|
11
|
+
a key that uniquely identifies the logical operation being performed.
|
|
12
|
+
"""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class IdempotencyStore(ABC):
|
|
6
|
+
"""Interface for pluggable idempotency result storage.
|
|
7
|
+
|
|
8
|
+
Implementations must be safe to call from concurrent contexts
|
|
9
|
+
(threads and/or async tasks, depending on where the store is used).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def get(self, key: str) -> Optional[Any]:
|
|
14
|
+
"""Return the cached result for `key`, or None if not present
|
|
15
|
+
or expired."""
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def set(self, key: str, value: Any, ttl_seconds: int) -> None:
|
|
20
|
+
"""Store `value` under `key` with the given time-to-live."""
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def exists(self, key: str) -> bool:
|
|
25
|
+
"""Return True if a non-expired entry exists for `key`."""
|
|
26
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any, Dict, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from latch.stores.base import IdempotencyStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InMemoryStore(IdempotencyStore):
|
|
9
|
+
"""Thread-safe in-memory idempotency store.
|
|
10
|
+
|
|
11
|
+
Not persistent across process restarts and not shared across
|
|
12
|
+
processes/nodes — suitable for development, tests, and single-process
|
|
13
|
+
deployments. Use a distributed store (e.g. Redis, planned for v0.2)
|
|
14
|
+
for multi-process or multi-node production deployments.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._data: Dict[str, Tuple[Any, float]] = {}
|
|
19
|
+
self._lock = threading.Lock()
|
|
20
|
+
|
|
21
|
+
def get(self, key: str) -> Optional[Any]:
|
|
22
|
+
with self._lock:
|
|
23
|
+
entry = self._data.get(key)
|
|
24
|
+
if entry is None:
|
|
25
|
+
return None
|
|
26
|
+
value, expires_at = entry
|
|
27
|
+
if time.time() > expires_at:
|
|
28
|
+
del self._data[key]
|
|
29
|
+
return None
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
def set(self, key: str, value: Any, ttl_seconds: int) -> None:
|
|
33
|
+
with self._lock:
|
|
34
|
+
self._data[key] = (value, time.time() + ttl_seconds)
|
|
35
|
+
|
|
36
|
+
def exists(self, key: str) -> bool:
|
|
37
|
+
return self.get(key) is not None
|
|
38
|
+
|
|
39
|
+
def clear(self) -> None:
|
|
40
|
+
"""Remove all entries. Primarily useful for tests."""
|
|
41
|
+
with self._lock:
|
|
42
|
+
self._data.clear()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from latch import IdempotencyKeyMissingError, InMemoryStore, idempotent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_sync_function_executes_once_for_same_key():
|
|
10
|
+
store = InMemoryStore()
|
|
11
|
+
calls = []
|
|
12
|
+
|
|
13
|
+
@idempotent(store=store)
|
|
14
|
+
def create_order(order_id, amount):
|
|
15
|
+
calls.append(order_id)
|
|
16
|
+
return {"order_id": order_id, "amount": amount, "status": "created"}
|
|
17
|
+
|
|
18
|
+
result1 = create_order(order_id="A1", amount=42.0, idempotency_key="key-1")
|
|
19
|
+
result2 = create_order(order_id="A1", amount=42.0, idempotency_key="key-1")
|
|
20
|
+
|
|
21
|
+
assert result1 == result2
|
|
22
|
+
assert len(calls) == 1 # only executed once
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_sync_function_executes_again_for_different_key():
|
|
26
|
+
store = InMemoryStore()
|
|
27
|
+
calls = []
|
|
28
|
+
|
|
29
|
+
@idempotent(store=store)
|
|
30
|
+
def create_order(order_id):
|
|
31
|
+
calls.append(order_id)
|
|
32
|
+
return {"order_id": order_id}
|
|
33
|
+
|
|
34
|
+
create_order(order_id="A1", idempotency_key="key-1")
|
|
35
|
+
create_order(order_id="A1", idempotency_key="key-2")
|
|
36
|
+
|
|
37
|
+
assert len(calls) == 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_missing_key_raises():
|
|
41
|
+
@idempotent()
|
|
42
|
+
def do_thing():
|
|
43
|
+
return "done"
|
|
44
|
+
|
|
45
|
+
with pytest.raises(IdempotencyKeyMissingError):
|
|
46
|
+
do_thing()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_exception_is_not_cached():
|
|
50
|
+
store = InMemoryStore()
|
|
51
|
+
attempts = []
|
|
52
|
+
|
|
53
|
+
@idempotent(store=store)
|
|
54
|
+
def flaky(idempotency_key=None):
|
|
55
|
+
attempts.append(1)
|
|
56
|
+
if len(attempts) == 1:
|
|
57
|
+
raise RuntimeError("transient failure")
|
|
58
|
+
return "ok"
|
|
59
|
+
|
|
60
|
+
with pytest.raises(RuntimeError):
|
|
61
|
+
flaky(idempotency_key="k1")
|
|
62
|
+
|
|
63
|
+
# Retry with the same key should re-execute, not return a cached failure.
|
|
64
|
+
result = flaky(idempotency_key="k1")
|
|
65
|
+
assert result == "ok"
|
|
66
|
+
assert len(attempts) == 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_ttl_expiry():
|
|
70
|
+
store = InMemoryStore()
|
|
71
|
+
calls = []
|
|
72
|
+
|
|
73
|
+
@idempotent(store=store, ttl_seconds=0)
|
|
74
|
+
def do_thing(idempotency_key=None):
|
|
75
|
+
calls.append(1)
|
|
76
|
+
return "done"
|
|
77
|
+
|
|
78
|
+
do_thing(idempotency_key="k1")
|
|
79
|
+
time.sleep(0.01) # ensure we're past the 0-second TTL
|
|
80
|
+
do_thing(idempotency_key="k1")
|
|
81
|
+
|
|
82
|
+
assert len(calls) == 2 # expired, so it re-executed
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_on_duplicate_callback_invoked():
|
|
86
|
+
store = InMemoryStore()
|
|
87
|
+
seen = []
|
|
88
|
+
|
|
89
|
+
@idempotent(store=store, on_duplicate=lambda key, result: seen.append((key, result)))
|
|
90
|
+
def do_thing(idempotency_key=None):
|
|
91
|
+
return "done"
|
|
92
|
+
|
|
93
|
+
do_thing(idempotency_key="k1")
|
|
94
|
+
do_thing(idempotency_key="k1")
|
|
95
|
+
|
|
96
|
+
assert seen == [("k1", "done")]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_async_function_dedupes():
|
|
101
|
+
store = InMemoryStore()
|
|
102
|
+
calls = []
|
|
103
|
+
|
|
104
|
+
@idempotent(store=store)
|
|
105
|
+
async def send_email(to, idempotency_key=None):
|
|
106
|
+
calls.append(to)
|
|
107
|
+
await asyncio.sleep(0)
|
|
108
|
+
return {"sent_to": to}
|
|
109
|
+
|
|
110
|
+
r1 = await send_email(to="a@example.com", idempotency_key="k1")
|
|
111
|
+
r2 = await send_email(to="a@example.com", idempotency_key="k1")
|
|
112
|
+
|
|
113
|
+
assert r1 == r2
|
|
114
|
+
assert len(calls) == 1
|