Repository navigation
Expand file tree
/
Copy pathidempotency.py
More file actions
288 lines (239 loc) · 10.3 KB
/
Copy pathidempotency.py
File metadata and controls
288 lines (239 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
"""Filesystem-backed idempotency memo for non-idempotent MCP tool operations.
An MCP client cannot distinguish "the request never reached the server" from
"the request reached the server, succeeded, and only the response was lost".
Both look identical: no response arrived. A retry policy that re-sends the
identical request therefore executes a plain create/trigger operation twice and
leaves two resources where one was intended.
The fix implemented here is the idempotency-key pattern applied one layer above
the upstream API (GitHub, Jira and Jenkins expose no idempotency key of their
own). The caller generates a key once, at the point it forms the intent to
create the resource, and reuses that same key unchanged across every retry of
that same intent. A key generated fresh per wire attempt provides zero
protection and is a misuse of this module.
The claim step uses ``os.open`` with ``O_CREAT | O_EXCL``, which is a single
atomic insert-if-absent operation on both POSIX and Windows. A plain
"read the memo, then write it if absent" would reproduce the identical
time-of-check-to-time-of-use race that this module exists to close, merely
relocated from the upstream endpoint to the memo itself: two concurrent retries
would both observe an absent key and both invoke the underlying operation.
Windows-Safe: ASCII only (cp1252 compatible)
"""
import hashlib
import json
import os
import time
from pathlib import Path
from typing import Any, Callable, Dict, Optional
DEFAULT_TTL_SECONDS = 24 * 60 * 60
DEFAULT_PENDING_TIMEOUT_SECONDS = 15 * 60
_STATUS_PENDING = "pending"
_STATUS_COMPLETED = "completed"
class DuplicateOperationInFlight(RuntimeError):
"""Raised when a key is claimed by an operation that has not yet finished.
Signals that an identical logical operation is currently executing. The
correct caller response is to wait and query the upstream system, never to
execute the operation again under a fresh key.
"""
def _store_root() -> Path:
"""Return the root directory holding idempotency records.
Honours the MCP_IDEMPOTENCY_DIR environment variable so deployments can
place the memo on a volume shared by every process that serves the same
logical caller.
Returns:
Path to the record root directory.
"""
override = os.environ.get("MCP_IDEMPOTENCY_DIR", "").strip()
if override:
return Path(override)
return Path.home() / ".claude" / "memory" / "mcp-idempotency"
class IdempotencyStore:
"""Atomic insert-if-absent memo mapping an operation key to its outcome.
Args:
namespace: Logical operation family, e.g. "github_create_issue".
Keys are scoped per namespace so the same caller-supplied key used
for two different operations cannot collide.
ttl_seconds: Age after which a completed record stops suppressing a
replay and the key may be claimed again.
pending_timeout_seconds: Age after which a claimed-but-never-completed
record is treated as abandoned by a crashed process.
"""
def __init__(
self,
namespace: str,
ttl_seconds: int = DEFAULT_TTL_SECONDS,
pending_timeout_seconds: int = DEFAULT_PENDING_TIMEOUT_SECONDS,
) -> None:
self._namespace = namespace
self._ttl = ttl_seconds
self._pending_timeout = pending_timeout_seconds
self._dir = _store_root() / namespace
def _path(self, key: str) -> Path:
"""Return the record path for a key.
The filename is the SHA-256 of the key rather than the key itself: the
key is caller-supplied and may embed identifiers that have no business
appearing in a directory listing, and hashing also removes any path
separator or reserved-name hazard from the filename.
Args:
key: Caller-supplied idempotency key.
Returns:
Path of the JSON record backing this key.
"""
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
return self._dir / (digest + ".json")
def claim(self, key: str) -> Optional[Dict[str, Any]]:
"""Atomically claim a key, or return the record that already holds it.
Args:
key: Caller-supplied idempotency key.
Returns:
None when the key was claimed by this call and the caller must now
execute the underlying operation. Otherwise the existing record
dict, whose ``status`` is either "completed" or "pending".
"""
path = self._path(key)
self._dir.mkdir(parents=True, exist_ok=True)
record = {
"status": _STATUS_PENDING,
"key_sha256": hashlib.sha256(key.encode("utf-8")).hexdigest(),
"namespace": self._namespace,
"claimed_at": time.time(),
"pid": os.getpid(),
}
payload = json.dumps(record, default=str)
for _ in range(2):
try:
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
existing = self._read(path)
if existing is None:
self._discard(path)
continue
if self._is_reclaimable(existing):
self._discard(path)
continue
return existing
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(payload)
except OSError:
self._discard(path)
raise
return None
raise RuntimeError(
"Could not claim idempotency key in namespace '{}': the record is "
"being reclaimed concurrently".format(self._namespace)
)
def complete(self, key: str, result: Dict[str, Any]) -> None:
"""Record the outcome of a successfully executed operation.
Args:
key: The key claimed by a prior ``claim`` call.
result: The tool result dict to replay on any later retry.
"""
path = self._path(key)
record = {
"status": _STATUS_COMPLETED,
"key_sha256": hashlib.sha256(key.encode("utf-8")).hexdigest(),
"namespace": self._namespace,
"completed_at": time.time(),
"result": result,
}
temp = path.with_suffix(".tmp")
temp.write_text(json.dumps(record, default=str), encoding="utf-8")
temp.replace(path)
def release(self, key: str) -> None:
"""Drop a claim so a failed operation can be retried under the same key.
Args:
key: The key claimed by a prior ``claim`` call.
"""
self._discard(self._path(key))
def _is_reclaimable(self, record: Dict[str, Any]) -> bool:
"""Return True when an existing record no longer suppresses execution.
Args:
record: A previously stored record dict.
Returns:
True when the record is an expired completion or an abandoned claim.
"""
now = time.time()
status = record.get("status")
if status == _STATUS_COMPLETED:
return (now - float(record.get("completed_at", 0.0))) > self._ttl
return (now - float(record.get("claimed_at", 0.0))) > self._pending_timeout
@staticmethod
def _read(path: Path) -> Optional[Dict[str, Any]]:
"""Read a record, returning None when it is missing or unreadable.
Args:
path: Record path.
Returns:
Parsed record dict, or None when the file is absent, empty, or not
valid JSON (which happens when a claim was interrupted mid-write).
"""
try:
raw = path.read_text(encoding="utf-8")
except (FileNotFoundError, OSError):
return None
if not raw.strip():
return None
try:
data = json.loads(raw)
except (json.JSONDecodeError, ValueError):
return None
return data if isinstance(data, dict) else None
@staticmethod
def _discard(path: Path) -> None:
"""Delete a record, tolerating a concurrent deleter.
Args:
path: Record path.
"""
try:
path.unlink()
except (FileNotFoundError, OSError):
pass
def run_once(
namespace: str,
key: Optional[str],
operation: Callable[[], Dict[str, Any]],
ttl_seconds: int = DEFAULT_TTL_SECONDS,
) -> Dict[str, Any]:
"""Execute a non-idempotent operation at most once per idempotency key.
Args:
namespace: Logical operation family used to scope the key.
key: Caller-supplied idempotency key, generated once per logical
operation and reused across all its retries. When None or empty the
operation runs unprotected, which preserves the previous behaviour
for callers that have not adopted keys.
operation: Zero-argument callable performing the underlying mutation.
ttl_seconds: Replay window for a recorded outcome.
Returns:
The operation result, annotated with ``idempotency_key`` and
``idempotent_replay``. On a replay the recorded first outcome is
returned and the underlying operation is not executed again.
Raises:
DuplicateOperationInFlight: If an identical logical operation is
already executing and has not yet recorded an outcome.
"""
if not key:
return operation()
store = IdempotencyStore(namespace, ttl_seconds=ttl_seconds)
existing = store.claim(key)
if existing is not None:
if existing.get("status") == _STATUS_COMPLETED:
replay = dict(existing.get("result") or {})
replay["idempotency_key"] = key
replay["idempotent_replay"] = True
return replay
raise DuplicateOperationInFlight(
"An operation with idempotency key '{}' in namespace '{}' is "
"already in flight. Query the upstream system for its outcome "
"instead of reissuing the request under a new key.".format(
key, namespace
)
)
try:
result = operation()
except BaseException:
store.release(key)
raise
store.complete(key, result)
recorded = dict(result)
recorded["idempotency_key"] = key
recorded["idempotent_replay"] = False
return recorded