mnfst 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.
- mnfst/__init__.py +43 -0
- mnfst/config.py +37 -0
- mnfst/gate.py +51 -0
- mnfst/heal_api.py +242 -0
- mnfst/merge.py +23 -0
- mnfst/outbound.py +368 -0
- mnfst/py.typed +1 -0
- mnfst/response_capture.py +169 -0
- mnfst/version.py +5 -0
- mnfst/wire.py +155 -0
- mnfst-0.1.0.dist-info/METADATA +106 -0
- mnfst-0.1.0.dist-info/RECORD +13 -0
- mnfst-0.1.0.dist-info/WHEEL +4 -0
mnfst/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""manifest(): repair failing API requests on the fly, based on the API's error.
|
|
2
|
+
|
|
3
|
+
from mnfst import manifest
|
|
4
|
+
manifest() # once, at startup
|
|
5
|
+
|
|
6
|
+
Instruments the process's HTTP clients (httpx and requests). When a call your
|
|
7
|
+
app makes fails, the failing request and the API's error go to Phoenix; if the
|
|
8
|
+
server returns a repaired body, the call is retried once. Successes are never
|
|
9
|
+
touched. The surface is three options: key, url, on_heal — everything that
|
|
10
|
+
is policy (which providers and endpoints get healed, and how hard the server
|
|
11
|
+
tries) is server-side configuration, editable in the dashboard.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import atexit
|
|
16
|
+
import warnings
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
from .config import resolve_config
|
|
20
|
+
from .heal_api import HealEvent
|
|
21
|
+
from .outbound import flush, install_outbound, installed_config
|
|
22
|
+
from .version import VERSION
|
|
23
|
+
|
|
24
|
+
__all__ = ["manifest", "flush", "HealEvent", "VERSION"]
|
|
25
|
+
|
|
26
|
+
atexit.register(flush, 2.0)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def manifest(*, key: Optional[str] = None, url: Optional[str] = None,
|
|
30
|
+
on_heal: Optional[Callable] = None) -> None:
|
|
31
|
+
config = resolve_config(api_key=key, url=url, on_heal=on_heal)
|
|
32
|
+
if config.api_key is None:
|
|
33
|
+
warnings.warn("mnfst: MNFST_KEY is not set; mnfst is disabled.",
|
|
34
|
+
stacklevel=2)
|
|
35
|
+
return
|
|
36
|
+
# Patching is process-global and one-shot. A second call with different
|
|
37
|
+
# options cannot take effect, so say so instead of pretending.
|
|
38
|
+
existing = installed_config()
|
|
39
|
+
if existing is not None and existing != config:
|
|
40
|
+
warnings.warn("mnfst: already installed with a different configuration; "
|
|
41
|
+
"reconfiguring requires a restart. "
|
|
42
|
+
"The first configuration stays in effect.", stacklevel=2)
|
|
43
|
+
install_outbound(config)
|
mnfst/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Options resolution: kwargs beat environment beats defaults.
|
|
2
|
+
|
|
3
|
+
The surface is deliberately tiny: credentials, server, and a local
|
|
4
|
+
observability hook. Everything that is policy — whether a given app,
|
|
5
|
+
provider, endpoint, or direction gets healed, and how long the server may
|
|
6
|
+
spend finding a fix — lives server-side, where it is editable in the
|
|
7
|
+
dashboard without a deploy. The SDK only replays when the server hands it
|
|
8
|
+
a healed body, so the server can enforce all of that with no client knob.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Callable, Optional
|
|
15
|
+
|
|
16
|
+
HOSTED_URL = "https://api.manifest.build"
|
|
17
|
+
|
|
18
|
+
# Hard client-side cap on a heal round-trip. Not configuration: fail-open
|
|
19
|
+
# needs a deadline even when the server misbehaves. How long the server
|
|
20
|
+
# actually spends investigating is server-side policy under this bound.
|
|
21
|
+
HEAL_TIMEOUT_SECONDS = 60.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Config:
|
|
26
|
+
api_key: Optional[str]
|
|
27
|
+
base_url: str
|
|
28
|
+
on_heal: Optional[Callable]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_config(api_key: Optional[str] = None, url: Optional[str] = None,
|
|
32
|
+
on_heal: Optional[Callable] = None) -> Config:
|
|
33
|
+
return Config(
|
|
34
|
+
api_key=api_key or os.environ.get("MNFST_KEY") or None,
|
|
35
|
+
base_url=(url or os.environ.get("MNFST_URL") or HOSTED_URL).rstrip("/"),
|
|
36
|
+
on_heal=on_heal,
|
|
37
|
+
)
|
mnfst/gate.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Capture any method, but only request-side failures.
|
|
2
|
+
|
|
3
|
+
400/404/422 are the statuses where the failure is the request's fault —
|
|
4
|
+
the only failures worth reporting and the only ones worth repairing.
|
|
5
|
+
401/403 (auth), 402 (billing), 429 (rate limits) and every 5xx are excluded
|
|
6
|
+
by design: editing the request cannot help, and reporting them is noise.
|
|
7
|
+
|
|
8
|
+
That status gate is the client's only eligibility rule. Whether a captured
|
|
9
|
+
failure gets retried is the server's call: the SDK retries when Phoenix
|
|
10
|
+
hands back a healed request (CONTRACT §4).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
GATED_STATUSES = frozenset({400, 404, 422})
|
|
18
|
+
REQUEST_BODY_LIMIT = 262144 # past this a body is a payload, not a form to repair
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def should_capture(status: int) -> bool:
|
|
22
|
+
return status in GATED_STATUSES
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_json_body(body_bytes: Optional[bytes]) -> Any:
|
|
26
|
+
"""The request body as JSON — object, array or scalar — else None (absent,
|
|
27
|
+
huge, not JSON). Fails open on anything: deep nesting raises
|
|
28
|
+
RecursionError, and no parse failure may ever break the caller's request."""
|
|
29
|
+
if not body_bytes or len(body_bytes) > REQUEST_BODY_LIMIT:
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
parsed = json.loads(body_bytes)
|
|
33
|
+
return parsed if bounded_json(parsed) else None
|
|
34
|
+
except Exception:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def bounded_json(value: Any, max_depth: int = 64) -> bool:
|
|
39
|
+
"""Bound depth and work independently of interpreter recursion behavior."""
|
|
40
|
+
stack = [(value, 0)]
|
|
41
|
+
visited = 0
|
|
42
|
+
while stack:
|
|
43
|
+
value, depth = stack.pop()
|
|
44
|
+
visited += 1
|
|
45
|
+
if depth > max_depth or visited > 100_000:
|
|
46
|
+
return False
|
|
47
|
+
if isinstance(value, dict):
|
|
48
|
+
stack.extend((child, depth + 1) for child in value.values())
|
|
49
|
+
elif isinstance(value, list):
|
|
50
|
+
stack.extend((child, depth + 1) for child in value)
|
|
51
|
+
return True
|
mnfst/heal_api.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""The SDK's own calls to Phoenix. Everything here fails soft: a heal that
|
|
2
|
+
cannot complete returns None and the caller serves the original response."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
import threading
|
|
7
|
+
import queue
|
|
8
|
+
import logging
|
|
9
|
+
import anyio
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .config import HEAL_TIMEOUT_SECONDS, Config
|
|
17
|
+
from .version import VERSION
|
|
18
|
+
from .gate import bounded_json
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("mnfst")
|
|
21
|
+
_HEAL_SLOTS = threading.BoundedSemaphore(8)
|
|
22
|
+
MAX_HEAL_RESPONSE = 1_048_576
|
|
23
|
+
|
|
24
|
+
def _bounded_call(call):
|
|
25
|
+
if not _HEAL_SLOTS.acquire(blocking=False):
|
|
26
|
+
return None
|
|
27
|
+
result = queue.Queue(maxsize=1)
|
|
28
|
+
def run():
|
|
29
|
+
try:
|
|
30
|
+
result.put(call())
|
|
31
|
+
except Exception:
|
|
32
|
+
result.put(None)
|
|
33
|
+
finally:
|
|
34
|
+
_HEAL_SLOTS.release()
|
|
35
|
+
try:
|
|
36
|
+
threading.Thread(target=run, daemon=True).start()
|
|
37
|
+
except Exception:
|
|
38
|
+
_HEAL_SLOTS.release()
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return result.get(timeout=HEAL_TIMEOUT_SECONDS)
|
|
42
|
+
except queue.Empty:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
internal_call = contextvars.ContextVar("mnfst_internal_call", default=False)
|
|
47
|
+
|
|
48
|
+
DISABLED_BACKOFF_SECONDS = 300
|
|
49
|
+
MAX_INFLIGHT_REPORTS = 64
|
|
50
|
+
|
|
51
|
+
# An attempt Phoenix opened that we never replayed (strip mode, or nothing
|
|
52
|
+
# replayable came back). Reported so the attempt ledger closes instead of
|
|
53
|
+
# holding open an answer that will never arrive.
|
|
54
|
+
NOT_ATTEMPTED = "replay_not_attempted"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class HealEvent:
|
|
59
|
+
url: str
|
|
60
|
+
status_code: int
|
|
61
|
+
heal_status: str
|
|
62
|
+
replay_status_code: Optional[int]
|
|
63
|
+
heal_ms: int
|
|
64
|
+
operations: Optional[list]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _headers(config: Config) -> dict:
|
|
68
|
+
headers = {
|
|
69
|
+
"user-agent": f"mnfst-python/{VERSION}",
|
|
70
|
+
"x-mnfst-source": "python-sdk",
|
|
71
|
+
}
|
|
72
|
+
if config.api_key:
|
|
73
|
+
headers["authorization"] = f"Bearer {config.api_key}"
|
|
74
|
+
return headers
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Disable:
|
|
78
|
+
"""Shared kill-switch backoff state."""
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
self._disabled_until = 0.0
|
|
82
|
+
|
|
83
|
+
def enabled(self) -> bool:
|
|
84
|
+
return time.monotonic() >= self._disabled_until
|
|
85
|
+
|
|
86
|
+
def trip(self) -> None:
|
|
87
|
+
self._disabled_until = time.monotonic() + DISABLED_BACKOFF_SECONDS
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_app_disabled(response: httpx.Response) -> bool:
|
|
91
|
+
if response.status_code != 403:
|
|
92
|
+
return False
|
|
93
|
+
try:
|
|
94
|
+
body = response.json()
|
|
95
|
+
return isinstance(body, dict) and (body.get("error") == "project_disabled" or body.get("status") == "app_disabled")
|
|
96
|
+
except ValueError:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class HealApi:
|
|
101
|
+
def __init__(self, config: Config, transport: Optional[httpx.BaseTransport] = None):
|
|
102
|
+
self._client = httpx.Client(base_url=config.base_url, timeout=HEAL_TIMEOUT_SECONDS,
|
|
103
|
+
transport=transport, headers=_headers(config))
|
|
104
|
+
self._disable = _Disable()
|
|
105
|
+
self._pending: list[threading.Thread] = []
|
|
106
|
+
self._pending_lock = threading.Lock()
|
|
107
|
+
self.report_failures = 0
|
|
108
|
+
self.reports_dropped = 0
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def _disabled_until(self) -> float:
|
|
112
|
+
return self._disable._disabled_until
|
|
113
|
+
|
|
114
|
+
@_disabled_until.setter
|
|
115
|
+
def _disabled_until(self, value: float) -> None:
|
|
116
|
+
self._disable._disabled_until = value
|
|
117
|
+
|
|
118
|
+
def healing_enabled(self) -> bool:
|
|
119
|
+
return self._disable.enabled()
|
|
120
|
+
|
|
121
|
+
def heal(self, payload: dict) -> Optional[dict]:
|
|
122
|
+
return _bounded_call(lambda: self._heal(payload))
|
|
123
|
+
|
|
124
|
+
def _heal(self, payload: dict) -> Optional[dict]:
|
|
125
|
+
if not self._disable.enabled():
|
|
126
|
+
return None
|
|
127
|
+
token = internal_call.set(True)
|
|
128
|
+
try:
|
|
129
|
+
# The post() call stays inside the try: serializing a pathological
|
|
130
|
+
# payload can raise, and that must fail open like any other error.
|
|
131
|
+
if not bounded_json(payload):
|
|
132
|
+
return None
|
|
133
|
+
with self._client.stream("POST", "/v1/heal", json=payload) as streamed:
|
|
134
|
+
data = bytearray()
|
|
135
|
+
for chunk in streamed.iter_bytes():
|
|
136
|
+
if len(data) + len(chunk) > MAX_HEAL_RESPONSE:
|
|
137
|
+
return None
|
|
138
|
+
data.extend(chunk)
|
|
139
|
+
response = httpx.Response(streamed.status_code, content=bytes(data))
|
|
140
|
+
if _is_app_disabled(response):
|
|
141
|
+
self._disable.trip()
|
|
142
|
+
return None
|
|
143
|
+
if response.status_code != 200:
|
|
144
|
+
return None
|
|
145
|
+
result = response.json()
|
|
146
|
+
return result if isinstance(result, dict) else None
|
|
147
|
+
except Exception:
|
|
148
|
+
return None
|
|
149
|
+
finally:
|
|
150
|
+
internal_call.reset(token)
|
|
151
|
+
|
|
152
|
+
def report_outcome(self, heal_attempt_id: str, retry_status_code: int,
|
|
153
|
+
error: Any = None, truncated: bool = False) -> None:
|
|
154
|
+
if retry_status_code == 0:
|
|
155
|
+
body = {"failure": {"kind": "not_attempted" if error == NOT_ATTEMPTED else "transport_error", "message": error or NOT_ATTEMPTED}}
|
|
156
|
+
else:
|
|
157
|
+
body = {"response": {"statusCode": retry_status_code}}
|
|
158
|
+
if error is not None:
|
|
159
|
+
body["response"].update(body=error, truncated=truncated)
|
|
160
|
+
|
|
161
|
+
def _send() -> None:
|
|
162
|
+
internal_call.set(True)
|
|
163
|
+
try:
|
|
164
|
+
response = self._client.patch(f"/v1/heal-attempts/{heal_attempt_id}", json=body)
|
|
165
|
+
response.raise_for_status()
|
|
166
|
+
except Exception:
|
|
167
|
+
with self._pending_lock:
|
|
168
|
+
self.report_failures += 1
|
|
169
|
+
logger.warning("Outcome report failed; attempt remains unconfirmed")
|
|
170
|
+
|
|
171
|
+
thread = threading.Thread(target=_send, daemon=True)
|
|
172
|
+
with self._pending_lock:
|
|
173
|
+
# Reports are fire-and-forget, so nothing else ever clears this in
|
|
174
|
+
# a long-running process — sweep the finished ones as we go, and
|
|
175
|
+
# under a flood drop the report rather than spawn without bound:
|
|
176
|
+
# a lost outcome costs one learning signal, not the host process.
|
|
177
|
+
self._pending = [t for t in self._pending if t.is_alive()]
|
|
178
|
+
if len(self._pending) >= MAX_INFLIGHT_REPORTS:
|
|
179
|
+
self.reports_dropped += 1
|
|
180
|
+
logger.warning("Outcome report capacity reached; report dropped")
|
|
181
|
+
return
|
|
182
|
+
self._pending.append(thread)
|
|
183
|
+
thread.start()
|
|
184
|
+
|
|
185
|
+
def join_pending_reports(self, timeout: float = 5.0) -> None:
|
|
186
|
+
deadline = time.monotonic() + timeout
|
|
187
|
+
with self._pending_lock:
|
|
188
|
+
pending = list(self._pending)
|
|
189
|
+
for thread in pending:
|
|
190
|
+
thread.join(max(0, deadline - time.monotonic()))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class AsyncHealApi:
|
|
194
|
+
def __init__(self, config: Config, transport: Optional[httpx.AsyncBaseTransport] = None):
|
|
195
|
+
self._client = httpx.AsyncClient(base_url=config.base_url, timeout=HEAL_TIMEOUT_SECONDS,
|
|
196
|
+
transport=transport, headers=_headers(config))
|
|
197
|
+
self._sync = HealApi(config) # outcome reports go out on threads either way
|
|
198
|
+
self._disable = self._sync._disable
|
|
199
|
+
|
|
200
|
+
def healing_enabled(self) -> bool:
|
|
201
|
+
return self._disable.enabled()
|
|
202
|
+
|
|
203
|
+
async def heal(self, payload: dict) -> Optional[dict]:
|
|
204
|
+
try:
|
|
205
|
+
with anyio.fail_after(HEAL_TIMEOUT_SECONDS):
|
|
206
|
+
return await self._heal(payload)
|
|
207
|
+
except TimeoutError:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
async def _heal(self, payload: dict) -> Optional[dict]:
|
|
211
|
+
if not self._disable.enabled():
|
|
212
|
+
return None
|
|
213
|
+
token = internal_call.set(True)
|
|
214
|
+
try:
|
|
215
|
+
# Same fail-open envelope as the sync client, serialization included.
|
|
216
|
+
if not bounded_json(payload):
|
|
217
|
+
return None
|
|
218
|
+
async with self._client.stream("POST", "/v1/heal", json=payload) as streamed:
|
|
219
|
+
data = bytearray()
|
|
220
|
+
async for chunk in streamed.aiter_bytes():
|
|
221
|
+
if len(data) + len(chunk) > MAX_HEAL_RESPONSE:
|
|
222
|
+
return None
|
|
223
|
+
data.extend(chunk)
|
|
224
|
+
response = httpx.Response(streamed.status_code, content=bytes(data))
|
|
225
|
+
if _is_app_disabled(response):
|
|
226
|
+
self._disable.trip()
|
|
227
|
+
return None
|
|
228
|
+
if response.status_code != 200:
|
|
229
|
+
return None
|
|
230
|
+
result = response.json()
|
|
231
|
+
return result if isinstance(result, dict) else None
|
|
232
|
+
except Exception:
|
|
233
|
+
return None
|
|
234
|
+
finally:
|
|
235
|
+
internal_call.reset(token)
|
|
236
|
+
|
|
237
|
+
def report_outcome(self, heal_attempt_id: str, retry_status_code: int,
|
|
238
|
+
error: Any = None, truncated: bool = False) -> None:
|
|
239
|
+
self._sync.report_outcome(heal_attempt_id, retry_status_code, error, truncated)
|
|
240
|
+
|
|
241
|
+
def join_pending_reports(self, timeout: float = 5.0) -> None:
|
|
242
|
+
self._sync.join_pending_reports(timeout)
|
mnfst/merge.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Settle a healed body against the one the caller sent (CONTRACT §4).
|
|
2
|
+
|
|
3
|
+
Objects merge: the healed object decides every key it names, a key it
|
|
4
|
+
omits is dropped (deletion-by-omission is how a field gets removed), and
|
|
5
|
+
keys that never travelled — the credential-named ones withheld from the
|
|
6
|
+
wire — are restored from the caller's copy unless the healed object names
|
|
7
|
+
them. Anything else (arrays, scalars, a body that changed type) is replaced
|
|
8
|
+
wholesale.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def merge_healed_body(original: Any, traveled: Any, healed: Any) -> Any:
|
|
16
|
+
if not isinstance(original, dict) or not isinstance(healed, dict):
|
|
17
|
+
return healed
|
|
18
|
+
merged = dict(healed)
|
|
19
|
+
traveled_keys = traveled.keys() if isinstance(traveled, dict) else ()
|
|
20
|
+
for key, value in original.items():
|
|
21
|
+
if key not in traveled_keys and key not in merged:
|
|
22
|
+
merged[key] = value
|
|
23
|
+
return merged
|
mnfst/outbound.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Auto-instrumentation of outbound HTTP clients (httpx and requests).
|
|
2
|
+
Patches at TRANSPORT level: one hook per client library, so every client —
|
|
3
|
+
including ones created before manifest() ran — is covered, and redirects,
|
|
4
|
+
retries and streaming stay the client's business. The internal_call guard
|
|
5
|
+
keeps the SDK's own Phoenix calls out of the loop.
|
|
6
|
+
|
|
7
|
+
Per captured failure (CONTRACT §1): capture → heal → apply the server's
|
|
8
|
+
healedRequest (url / headers / body) → retry once → report the outcome.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Any, Mapping, Optional
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from .config import Config
|
|
21
|
+
from .gate import parse_json_body, should_capture
|
|
22
|
+
from .heal_api import NOT_ATTEMPTED, AsyncHealApi, HealApi, HealEvent, internal_call
|
|
23
|
+
from .merge import merge_healed_body
|
|
24
|
+
from .response_capture import capture_httpx, capture_httpx_async, capture_requests
|
|
25
|
+
from .wire import capped_response_body, heal_payload, safe_error_text, traveling_body
|
|
26
|
+
|
|
27
|
+
_installed = False
|
|
28
|
+
_installed_config: Optional[Config] = None
|
|
29
|
+
_originals: dict = {}
|
|
30
|
+
_reporters: list = []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def flush(timeout: float = 5.0) -> None:
|
|
34
|
+
"""Wait up to timeout seconds total for outstanding outcome reports."""
|
|
35
|
+
deadline = time.monotonic() + max(0, timeout)
|
|
36
|
+
for api in _reporters:
|
|
37
|
+
api.join_pending_reports(max(0, deadline - time.monotonic()))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def installed_config() -> Optional[Config]:
|
|
41
|
+
"""The config the process is instrumented with, or None. Patching is
|
|
42
|
+
process-global and one-shot, so a second manifest() with different options
|
|
43
|
+
cannot take effect — the entry point warns instead of silently ignoring."""
|
|
44
|
+
return _installed_config
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- the loop, client-agnostic ---------------------------------------------
|
|
48
|
+
|
|
49
|
+
class _Capture:
|
|
50
|
+
"""Everything the SDK knows about one failing call."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, method: str, url: str, headers: Mapping[str, Any],
|
|
53
|
+
content: Optional[bytes], status_code: int, raw_response: bytes,
|
|
54
|
+
response_time_ms: int, incomplete: bool = False):
|
|
55
|
+
self.method = method or "GET"
|
|
56
|
+
self.url = url
|
|
57
|
+
self.headers = headers
|
|
58
|
+
self.content = content # None = the body could not be read (streamed)
|
|
59
|
+
self.body = parse_json_body(content)
|
|
60
|
+
response_body, truncated = capped_response_body(raw_response)
|
|
61
|
+
self.payload = heal_payload(
|
|
62
|
+
trace_id=uuid.uuid4().hex, method=self.method, url=url, headers=headers,
|
|
63
|
+
body=self.body, status_code=status_code, response_body=response_body,
|
|
64
|
+
truncated=truncated or incomplete, response_time_ms=response_time_ms)
|
|
65
|
+
self.started = time.monotonic()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Retry:
|
|
69
|
+
"""A rebuilt request: the original with the server's changes applied."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, url: str, headers: dict, content: Optional[bytes]):
|
|
72
|
+
self.url = url
|
|
73
|
+
self.headers = headers
|
|
74
|
+
self.content = content
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _healed_request(result: Optional[dict]) -> Optional[dict]:
|
|
78
|
+
if not result or result.get("status") not in ("patched", "unverified"):
|
|
79
|
+
return None
|
|
80
|
+
healed = result.get("healedRequest")
|
|
81
|
+
if not isinstance(healed, dict) or not any(k in healed for k in ("url", "headers", "body")):
|
|
82
|
+
return None
|
|
83
|
+
return healed
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _apply(capture: _Capture, healed: dict) -> Optional[_Retry]:
|
|
87
|
+
"""CONTRACT §4: url replaces; headers set/replace, null removes; body
|
|
88
|
+
merges (objects) or replaces. Returns None when the retry cannot be
|
|
89
|
+
built (the original body was unreadable and the server sent no body)."""
|
|
90
|
+
url = healed.get("url") or capture.url
|
|
91
|
+
if not _same_origin(url, capture.url):
|
|
92
|
+
return None # a URL heal may move the path, never the host: the
|
|
93
|
+
# retry carries the caller's credentials (CONTRACT §4)
|
|
94
|
+
headers = {k: v for k, v in capture.headers.items() if k.lower() != "content-length"}
|
|
95
|
+
for name, value in (healed.get("headers") or {}).items():
|
|
96
|
+
headers = {k: v for k, v in headers.items() if k.lower() != str(name).lower()}
|
|
97
|
+
if value is not None:
|
|
98
|
+
headers[str(name)] = str(value)
|
|
99
|
+
if "body" in healed:
|
|
100
|
+
merged = merge_healed_body(capture.body, traveling_body(capture.body), healed["body"])
|
|
101
|
+
content: Optional[bytes] = json.dumps(merged).encode()
|
|
102
|
+
else:
|
|
103
|
+
content = capture.content
|
|
104
|
+
if content is None and capture.method not in ("GET", "HEAD", "DELETE", "OPTIONS"):
|
|
105
|
+
return None
|
|
106
|
+
return _Retry(url, headers, content)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _same_origin(url: str, original: str) -> bool:
|
|
110
|
+
try:
|
|
111
|
+
a, b = urlsplit(str(url)), urlsplit(str(original))
|
|
112
|
+
except Exception:
|
|
113
|
+
return False
|
|
114
|
+
return (a.scheme, a.netloc.lower()) == (b.scheme, b.netloc.lower())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _report(api, result: Optional[dict], retry_status: int,
|
|
118
|
+
error: Any = None, truncated: bool = False) -> None:
|
|
119
|
+
attempt_id = (result or {}).get("healAttemptId")
|
|
120
|
+
if attempt_id:
|
|
121
|
+
try:
|
|
122
|
+
api.report_outcome(attempt_id, retry_status, error, truncated)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _emit(config: Config, capture: _Capture, result: Optional[dict],
|
|
128
|
+
retry_status: Optional[int], replay_attempted: bool = True) -> None:
|
|
129
|
+
if not config.on_heal:
|
|
130
|
+
return
|
|
131
|
+
try:
|
|
132
|
+
status = (result or {}).get("status") or "heal_unreachable"
|
|
133
|
+
if (replay_attempted and result and retry_status is None
|
|
134
|
+
and status in ("patched", "unverified")):
|
|
135
|
+
status = "replay_failed"
|
|
136
|
+
config.on_heal(HealEvent(
|
|
137
|
+
url=capture.payload["request"]["url"],
|
|
138
|
+
status_code=capture.payload["response"]["statusCode"],
|
|
139
|
+
heal_status=status, replay_status_code=retry_status,
|
|
140
|
+
heal_ms=int((time.monotonic() - capture.started) * 1000),
|
|
141
|
+
operations=(result or {}).get("operations")))
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _decide(config: Config, api, capture: _Capture, result: Optional[dict]) -> Optional[_Retry]:
|
|
147
|
+
"""Turn a heal result into a retry, or close the attempt and return None."""
|
|
148
|
+
healed = None if capture.payload["response"]["truncated"] else _healed_request(result)
|
|
149
|
+
try:
|
|
150
|
+
retry = _apply(capture, healed) if healed else None
|
|
151
|
+
except Exception:
|
|
152
|
+
retry = None
|
|
153
|
+
if retry is None:
|
|
154
|
+
_report(api, result, 0, NOT_ATTEMPTED)
|
|
155
|
+
_emit(config, capture, result, None, replay_attempted=False)
|
|
156
|
+
return retry
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --- httpx -------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def _safe_request_content(request: httpx.Request) -> Optional[bytes]:
|
|
162
|
+
"""A streamed or iterator request body raises RequestNotRead on `.content`.
|
|
163
|
+
Capture without it; a retry then needs the server to supply a body."""
|
|
164
|
+
try:
|
|
165
|
+
content = request.content
|
|
166
|
+
except Exception:
|
|
167
|
+
return None
|
|
168
|
+
return content if isinstance(content, bytes) else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _rebuild(request: httpx.Request, retry: _Retry) -> httpx.Request:
|
|
172
|
+
# Extensions carry the per-request timeout (and the caller's trace hooks);
|
|
173
|
+
# dropping them would silently retry under the client default.
|
|
174
|
+
return httpx.Request(request.method, retry.url, headers=retry.headers,
|
|
175
|
+
content=retry.content, extensions=dict(request.extensions))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def install_outbound(config: Config, heal_api: Optional[HealApi] = None,
|
|
179
|
+
async_heal_api: Optional[AsyncHealApi] = None) -> None:
|
|
180
|
+
global _installed, _installed_config
|
|
181
|
+
if _installed:
|
|
182
|
+
return
|
|
183
|
+
sync_api = heal_api or HealApi(config)
|
|
184
|
+
async_api = async_heal_api or AsyncHealApi(config)
|
|
185
|
+
_installed = True
|
|
186
|
+
_installed_config = config
|
|
187
|
+
_reporters[:] = [sync_api, async_api]
|
|
188
|
+
|
|
189
|
+
_originals["httpx_sync"] = httpx.HTTPTransport.handle_request
|
|
190
|
+
_originals["httpx_async"] = httpx.AsyncHTTPTransport.handle_async_request
|
|
191
|
+
|
|
192
|
+
def patched_sync(self, request: httpx.Request) -> httpx.Response:
|
|
193
|
+
original = _originals["httpx_sync"]
|
|
194
|
+
started = time.monotonic()
|
|
195
|
+
response = original(self, request)
|
|
196
|
+
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
197
|
+
if internal_call.get():
|
|
198
|
+
return response
|
|
199
|
+
try:
|
|
200
|
+
# Status decides capture, before the response body is touched — a
|
|
201
|
+
# successful streamed response is never read here.
|
|
202
|
+
if not should_capture(response.status_code) or not sync_api.healing_enabled():
|
|
203
|
+
return response
|
|
204
|
+
try:
|
|
205
|
+
response, raw = capture_httpx(response)
|
|
206
|
+
except Exception:
|
|
207
|
+
return response
|
|
208
|
+
capture = _Capture(request.method, str(request.url), request.headers,
|
|
209
|
+
_safe_request_content(request), response.status_code,
|
|
210
|
+
raw, elapsed_ms, response.extensions.get("mnfst_capture_incomplete", False))
|
|
211
|
+
result = sync_api.heal(capture.payload)
|
|
212
|
+
retry = _decide(config, sync_api, capture, result)
|
|
213
|
+
if retry is None:
|
|
214
|
+
return response
|
|
215
|
+
try:
|
|
216
|
+
retried = original(self, _rebuild(request, retry))
|
|
217
|
+
retry_body, truncated = None, False
|
|
218
|
+
if retried.status_code >= 400:
|
|
219
|
+
retried, raw = capture_httpx(retried)
|
|
220
|
+
retry_body, truncated = capped_response_body(raw)
|
|
221
|
+
truncated = truncated or retried.extensions.get("mnfst_capture_incomplete", False)
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
_report(sync_api, result, 0, safe_error_text(exc))
|
|
224
|
+
_emit(config, capture, result, None)
|
|
225
|
+
return response
|
|
226
|
+
_report(sync_api, result, retried.status_code, retry_body, truncated)
|
|
227
|
+
_emit(config, capture, result, retried.status_code)
|
|
228
|
+
try:
|
|
229
|
+
response.close()
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
return retried
|
|
233
|
+
except Exception:
|
|
234
|
+
return response # nothing in here may reach the caller
|
|
235
|
+
|
|
236
|
+
async def patched_async(self, request: httpx.Request) -> httpx.Response:
|
|
237
|
+
original = _originals["httpx_async"]
|
|
238
|
+
started = time.monotonic()
|
|
239
|
+
response = await original(self, request)
|
|
240
|
+
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
241
|
+
if internal_call.get():
|
|
242
|
+
return response
|
|
243
|
+
try:
|
|
244
|
+
if not should_capture(response.status_code) or not async_api.healing_enabled():
|
|
245
|
+
return response
|
|
246
|
+
try:
|
|
247
|
+
response, raw = await capture_httpx_async(response)
|
|
248
|
+
except Exception:
|
|
249
|
+
return response
|
|
250
|
+
capture = _Capture(request.method, str(request.url), request.headers,
|
|
251
|
+
_safe_request_content(request), response.status_code,
|
|
252
|
+
raw, elapsed_ms, response.extensions.get("mnfst_capture_incomplete", False))
|
|
253
|
+
result = await async_api.heal(capture.payload)
|
|
254
|
+
retry = _decide(config, async_api, capture, result)
|
|
255
|
+
if retry is None:
|
|
256
|
+
return response
|
|
257
|
+
try:
|
|
258
|
+
retried = await original(self, _rebuild(request, retry))
|
|
259
|
+
retry_body, truncated = None, False
|
|
260
|
+
if retried.status_code >= 400:
|
|
261
|
+
retried, raw = await capture_httpx_async(retried)
|
|
262
|
+
retry_body, truncated = capped_response_body(raw)
|
|
263
|
+
truncated = truncated or retried.extensions.get("mnfst_capture_incomplete", False)
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
_report(async_api, result, 0, safe_error_text(exc))
|
|
266
|
+
_emit(config, capture, result, None)
|
|
267
|
+
return response
|
|
268
|
+
_report(async_api, result, retried.status_code, retry_body, truncated)
|
|
269
|
+
_emit(config, capture, result, retried.status_code)
|
|
270
|
+
try:
|
|
271
|
+
await response.aclose()
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
return retried
|
|
275
|
+
except Exception:
|
|
276
|
+
return response
|
|
277
|
+
|
|
278
|
+
httpx.HTTPTransport.handle_request = patched_sync
|
|
279
|
+
httpx.AsyncHTTPTransport.handle_async_request = patched_async
|
|
280
|
+
install_requests(config, sync_api)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def uninstall_outbound() -> None:
|
|
284
|
+
global _installed, _installed_config
|
|
285
|
+
if not _installed:
|
|
286
|
+
return
|
|
287
|
+
httpx.HTTPTransport.handle_request = _originals["httpx_sync"]
|
|
288
|
+
httpx.AsyncHTTPTransport.handle_async_request = _originals["httpx_async"]
|
|
289
|
+
uninstall_requests()
|
|
290
|
+
_installed = False
|
|
291
|
+
_installed_config = None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# --- requests ----------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def install_requests(config: Config, heal_api: HealApi) -> None:
|
|
297
|
+
try:
|
|
298
|
+
import requests.adapters
|
|
299
|
+
except ImportError:
|
|
300
|
+
return # requests is not a runtime dependency; nothing to patch
|
|
301
|
+
if "requests_send" in _originals:
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
_originals["requests_send"] = requests.adapters.HTTPAdapter.send
|
|
305
|
+
|
|
306
|
+
def patched_send(self, request, **kwargs):
|
|
307
|
+
original = _originals["requests_send"]
|
|
308
|
+
started = time.monotonic()
|
|
309
|
+
response = original(self, request, **kwargs)
|
|
310
|
+
# `.elapsed` is only stamped by Session.send, after the adapter
|
|
311
|
+
# returns — so time the call here instead.
|
|
312
|
+
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
313
|
+
if internal_call.get():
|
|
314
|
+
return response
|
|
315
|
+
try:
|
|
316
|
+
if not should_capture(response.status_code) or not heal_api.healing_enabled():
|
|
317
|
+
return response
|
|
318
|
+
try:
|
|
319
|
+
response, raw = capture_requests(response)
|
|
320
|
+
except Exception:
|
|
321
|
+
return response
|
|
322
|
+
# A generator/file body is not bytes or str — capture without it,
|
|
323
|
+
# so a streamed upload is never consumed.
|
|
324
|
+
body = request.body
|
|
325
|
+
content = body if isinstance(body, bytes) else \
|
|
326
|
+
(body.encode() if isinstance(body, str) else None)
|
|
327
|
+
capture = _Capture(request.method, request.url, request.headers, content,
|
|
328
|
+
response.status_code, raw, elapsed_ms, getattr(response, "_mnfst_capture_incomplete", False))
|
|
329
|
+
result = heal_api.heal(capture.payload)
|
|
330
|
+
retry = _decide(config, heal_api, capture, result)
|
|
331
|
+
if retry is None:
|
|
332
|
+
return response
|
|
333
|
+
try:
|
|
334
|
+
rebuilt = request.copy()
|
|
335
|
+
rebuilt.url = retry.url
|
|
336
|
+
rebuilt.headers.clear()
|
|
337
|
+
rebuilt.headers.update(retry.headers)
|
|
338
|
+
rebuilt.body = retry.content
|
|
339
|
+
if retry.content is not None:
|
|
340
|
+
rebuilt.headers["content-length"] = str(len(retry.content))
|
|
341
|
+
retried = original(self, rebuilt, **kwargs)
|
|
342
|
+
retry_body, truncated = None, False
|
|
343
|
+
if retried.status_code >= 400:
|
|
344
|
+
retried, raw = capture_requests(retried)
|
|
345
|
+
retry_body, truncated = capped_response_body(raw)
|
|
346
|
+
truncated = truncated or getattr(retried, "_mnfst_capture_incomplete", False)
|
|
347
|
+
except Exception as exc:
|
|
348
|
+
_report(heal_api, result, 0, safe_error_text(exc))
|
|
349
|
+
_emit(config, capture, result, None)
|
|
350
|
+
return response
|
|
351
|
+
_report(heal_api, result, retried.status_code, retry_body, truncated)
|
|
352
|
+
_emit(config, capture, result, retried.status_code)
|
|
353
|
+
try:
|
|
354
|
+
response.close()
|
|
355
|
+
except Exception:
|
|
356
|
+
pass
|
|
357
|
+
return retried
|
|
358
|
+
except Exception:
|
|
359
|
+
return response
|
|
360
|
+
|
|
361
|
+
requests.adapters.HTTPAdapter.send = patched_send
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def uninstall_requests() -> None:
|
|
365
|
+
if "requests_send" not in _originals:
|
|
366
|
+
return
|
|
367
|
+
import requests.adapters
|
|
368
|
+
requests.adapters.HTTPAdapter.send = _originals.pop("requests_send")
|
mnfst/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Bounded capture of failures without consuming the caller's response.
|
|
2
|
+
|
|
3
|
+
Keep a replayable raw prefix plus the remaining iterator. Successful responses
|
|
4
|
+
never enter this module. Capture uses the original client's read timeout.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import zlib
|
|
10
|
+
from typing import Iterator
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .wire import RESPONSE_BODY_CAP
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ReplayStream(httpx.SyncByteStream):
|
|
18
|
+
def __init__(self, chunks, iterator, original):
|
|
19
|
+
self.chunks, self.iterator, self.original = chunks, iterator, original
|
|
20
|
+
|
|
21
|
+
def __iter__(self):
|
|
22
|
+
yield from self.chunks
|
|
23
|
+
yield from self.iterator
|
|
24
|
+
|
|
25
|
+
def close(self):
|
|
26
|
+
self.original.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncReplayStream(httpx.AsyncByteStream):
|
|
30
|
+
def __init__(self, chunks, iterator, original):
|
|
31
|
+
self.chunks, self.iterator, self.original = chunks, iterator, original
|
|
32
|
+
|
|
33
|
+
async def __aiter__(self):
|
|
34
|
+
for chunk in self.chunks:
|
|
35
|
+
yield chunk
|
|
36
|
+
async for chunk in self.iterator:
|
|
37
|
+
yield chunk
|
|
38
|
+
|
|
39
|
+
async def aclose(self):
|
|
40
|
+
await self.original.aclose()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _decode(raw: bytes, encoding: str) -> bytes:
|
|
44
|
+
encoding = encoding.lower().strip()
|
|
45
|
+
if not encoding or encoding == 'identity':
|
|
46
|
+
return raw
|
|
47
|
+
if encoding not in ('gzip', 'deflate'):
|
|
48
|
+
# Unsupported content coding: capture metadata, never interpret bytes
|
|
49
|
+
# as provider prose. The original wire response remains untouched.
|
|
50
|
+
return b''
|
|
51
|
+
try:
|
|
52
|
+
decoder = zlib.decompressobj(31 if encoding == 'gzip' else 15)
|
|
53
|
+
return decoder.decompress(raw, RESPONSE_BODY_CAP + 1)
|
|
54
|
+
except zlib.error:
|
|
55
|
+
return b''
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def capture_httpx(response: httpx.Response):
|
|
59
|
+
if response.is_stream_consumed:
|
|
60
|
+
return response, response.content[:RESPONSE_BODY_CAP + 1]
|
|
61
|
+
chunks, size = [], 0
|
|
62
|
+
incomplete = True
|
|
63
|
+
iterator = iter(response.stream)
|
|
64
|
+
try:
|
|
65
|
+
for chunk in iterator:
|
|
66
|
+
chunks.append(chunk)
|
|
67
|
+
size += len(chunk)
|
|
68
|
+
if size > RESPONSE_BODY_CAP:
|
|
69
|
+
break
|
|
70
|
+
else:
|
|
71
|
+
incomplete = False
|
|
72
|
+
response.close()
|
|
73
|
+
except Exception:
|
|
74
|
+
# Replay consumed bytes and the original exception to the caller.
|
|
75
|
+
# A failed capture must not turn a truncated response into success.
|
|
76
|
+
import sys
|
|
77
|
+
error = sys.exception() if hasattr(sys, 'exception') else sys.exc_info()[1]
|
|
78
|
+
def failed():
|
|
79
|
+
raise error
|
|
80
|
+
yield # pragma: no cover
|
|
81
|
+
iterator = failed()
|
|
82
|
+
restored = httpx.Response(response.status_code, headers=response.headers,
|
|
83
|
+
extensions={**response.extensions, "mnfst_capture_incomplete": incomplete},
|
|
84
|
+
stream=ReplayStream(chunks, iterator, response))
|
|
85
|
+
raw = b''.join(chunks)[:RESPONSE_BODY_CAP + 1]
|
|
86
|
+
return restored, _decode(raw, response.headers.get('content-encoding', ''))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def capture_httpx_async(response: httpx.Response):
|
|
90
|
+
if response.is_stream_consumed:
|
|
91
|
+
return response, response.content[:RESPONSE_BODY_CAP + 1]
|
|
92
|
+
chunks, size = [], 0
|
|
93
|
+
incomplete = True
|
|
94
|
+
iterator = response.stream.__aiter__()
|
|
95
|
+
try:
|
|
96
|
+
async for chunk in iterator:
|
|
97
|
+
chunks.append(chunk)
|
|
98
|
+
size += len(chunk)
|
|
99
|
+
if size > RESPONSE_BODY_CAP:
|
|
100
|
+
break
|
|
101
|
+
else:
|
|
102
|
+
incomplete = False
|
|
103
|
+
await response.aclose()
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
error = exc
|
|
106
|
+
async def failed():
|
|
107
|
+
raise error
|
|
108
|
+
yield # pragma: no cover
|
|
109
|
+
iterator = failed()
|
|
110
|
+
restored = httpx.Response(response.status_code, headers=response.headers,
|
|
111
|
+
extensions={**response.extensions, "mnfst_capture_incomplete": incomplete},
|
|
112
|
+
stream=AsyncReplayStream(chunks, iterator, response))
|
|
113
|
+
raw = b''.join(chunks)[:RESPONSE_BODY_CAP + 1]
|
|
114
|
+
return restored, _decode(raw, response.headers.get('content-encoding', ''))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _Reader(io.RawIOBase):
|
|
118
|
+
def __init__(self, chunks: Iterator[bytes], original):
|
|
119
|
+
self.iterator, self.original, self.buffer = chunks, original, b''
|
|
120
|
+
|
|
121
|
+
def readable(self):
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
def readinto(self, target):
|
|
125
|
+
while not self.buffer:
|
|
126
|
+
self.buffer = next(self.iterator, b'')
|
|
127
|
+
if not self.buffer:
|
|
128
|
+
return 0
|
|
129
|
+
size = min(len(target), len(self.buffer))
|
|
130
|
+
target[:size], self.buffer = self.buffer[:size], self.buffer[size:]
|
|
131
|
+
return size
|
|
132
|
+
|
|
133
|
+
def close(self):
|
|
134
|
+
self.original.close()
|
|
135
|
+
super().close()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def capture_requests(response):
|
|
139
|
+
from itertools import chain
|
|
140
|
+
from urllib3.response import HTTPResponse
|
|
141
|
+
if response._content is not False:
|
|
142
|
+
return response, response.content[:RESPONSE_BODY_CAP + 1]
|
|
143
|
+
original = response.raw
|
|
144
|
+
chunks, size = [], 0
|
|
145
|
+
incomplete = True
|
|
146
|
+
iterator = original.stream(amt=65536, decode_content=False)
|
|
147
|
+
try:
|
|
148
|
+
for chunk in iterator:
|
|
149
|
+
chunks.append(chunk)
|
|
150
|
+
size += len(chunk)
|
|
151
|
+
if size > RESPONSE_BODY_CAP:
|
|
152
|
+
break
|
|
153
|
+
else:
|
|
154
|
+
incomplete = False
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
error = exc
|
|
157
|
+
def failed():
|
|
158
|
+
raise error
|
|
159
|
+
yield # pragma: no cover
|
|
160
|
+
iterator = failed()
|
|
161
|
+
response._mnfst_capture_incomplete = incomplete
|
|
162
|
+
reader = io.BufferedReader(_Reader(iter(chain(chunks, iterator)), original))
|
|
163
|
+
response.raw = HTTPResponse(body=reader, headers=dict(response.headers),
|
|
164
|
+
status=response.status_code, preload_content=False,
|
|
165
|
+
decode_content=False)
|
|
166
|
+
# requests extracts Set-Cookie from this stdlib response metadata.
|
|
167
|
+
response.raw._original_response = getattr(original, "_original_response", None)
|
|
168
|
+
raw = b''.join(chunks)[:RESPONSE_BODY_CAP + 1]
|
|
169
|
+
return response, _decode(raw, response.headers.get('content-encoding', ''))
|
mnfst/version.py
ADDED
mnfst/wire.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Build the /v1/heal payload. The SDK never parses error dialects —
|
|
2
|
+
the raw error body travels (capped) and the server normalizes (CONTRACT §3).
|
|
3
|
+
|
|
4
|
+
Everything about the failing request travels — URL with query, headers,
|
|
5
|
+
body — so the server has the whole context to heal with. Credential VALUES
|
|
6
|
+
never do (CONTRACT §6): query/header values are masked to REDACTED with
|
|
7
|
+
their names kept, and credential-named top-level body keys are withheld and
|
|
8
|
+
restored on retry by the merge. `safe_url` / `safe_headers` output is for
|
|
9
|
+
the wire only; never feed it back into a live request.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from typing import Any, Mapping, Tuple
|
|
16
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
17
|
+
|
|
18
|
+
RESPONSE_BODY_CAP = 65536
|
|
19
|
+
HEADER_VALUE_CAP = 1024
|
|
20
|
+
ERROR_TEXT_CAP = 512
|
|
21
|
+
|
|
22
|
+
# The client-side MINIMUM of credential-named fields (query params and body
|
|
23
|
+
# keys). Matched after normalization; the server may know more names.
|
|
24
|
+
SECRET_PARAMS = frozenset({
|
|
25
|
+
"api_key", "apikey", "api_token", "key", "token", "access_token", "refresh_token",
|
|
26
|
+
"auth", "authorization", "signature", "sig", "secret", "client_secret",
|
|
27
|
+
"password", "session", "session_id",
|
|
28
|
+
"bearer", "jwt", "id_token", "auth_token", "pwd", "passwd", "private_key",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
# Header names are matched on ROOTS: any header whose normalized name contains
|
|
32
|
+
# one carries a credential (authorization, proxy_authorization, x_api_key,
|
|
33
|
+
# x_goog_api_key, cookie, x_amz_security_token, ...). Over-masking a harmless
|
|
34
|
+
# header (idempotency_key) costs nothing — its presence still travels.
|
|
35
|
+
SECRET_HEADER_ROOTS = ("auth", "key", "token", "secret", "session", "password",
|
|
36
|
+
"passwd", "cookie", "signature", "credential", "bearer", "jwt")
|
|
37
|
+
|
|
38
|
+
_CAMEL_BOUNDARY = re.compile(r"([a-z0-9])([A-Z])")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _normalize(name: str) -> str:
|
|
42
|
+
"""X-Api-Key, apiKey and api_key are all the same secret."""
|
|
43
|
+
normalized = _CAMEL_BOUNDARY.sub(r"\1_\2", name).replace("-", "_").lower()
|
|
44
|
+
return normalized[2:] if normalized.startswith("x_") else normalized
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_secret_field(name: Any) -> bool:
|
|
48
|
+
return isinstance(name, str) and _normalize(name) in SECRET_PARAMS
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_secret_header(name: str) -> bool:
|
|
52
|
+
normalized = _normalize(name)
|
|
53
|
+
return normalized in SECRET_PARAMS or any(root in normalized for root in SECRET_HEADER_ROOTS)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def safe_url(url: str) -> str:
|
|
57
|
+
parts = urlsplit(url)
|
|
58
|
+
query = parts.query
|
|
59
|
+
if query:
|
|
60
|
+
pairs = parse_qsl(query, keep_blank_values=True)
|
|
61
|
+
query = urlencode([(k, "REDACTED" if is_secret_field(k) else v) for k, v in pairs])
|
|
62
|
+
# user:password@host is a credential too — keep host[:port] only.
|
|
63
|
+
netloc = parts.netloc.rsplit("@", 1)[-1]
|
|
64
|
+
return urlunsplit((parts.scheme, netloc, parts.path, query, ""))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def safe_headers(headers: Mapping[str, Any]) -> dict:
|
|
68
|
+
"""Every header travels, lowercased; credential values are masked."""
|
|
69
|
+
out: dict = {}
|
|
70
|
+
try:
|
|
71
|
+
for name, value in headers.items():
|
|
72
|
+
key = str(name).lower()
|
|
73
|
+
text = value.decode("latin-1") if isinstance(value, bytes) else str(value)
|
|
74
|
+
out[key] = "REDACTED" if is_secret_header(key) else text[:HEADER_VALUE_CAP]
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def traveling_body(body: Any) -> Any:
|
|
81
|
+
"""What of the body goes on the wire: an object minus its credential-named
|
|
82
|
+
top-level keys (the merge restores them on retry, CONTRACT §4); any other
|
|
83
|
+
JSON as-is."""
|
|
84
|
+
if isinstance(body, dict):
|
|
85
|
+
return {k: v for k, v in body.items() if not is_secret_field(k)}
|
|
86
|
+
return body
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# What a client's exception text embeds: a whole URL, or the rooted path plus
|
|
90
|
+
# query it failed on (requests says "Max retries exceeded with url: /p?key=…").
|
|
91
|
+
_URL_IN_TEXT = re.compile(
|
|
92
|
+
r"https?://[^\s'\"<>)\]}]+"
|
|
93
|
+
r"|/[^\s'\"<>)\]}]*\?[^\s'\"<>)\]}]+"
|
|
94
|
+
)
|
|
95
|
+
_PARAM_ASSIGNMENT = re.compile(r"([A-Za-z0-9_-]+)=([^\s&'\"<>)\]}]+)")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _mask_url_match(match: "re.Match") -> str:
|
|
99
|
+
try:
|
|
100
|
+
return safe_url(match.group(0))
|
|
101
|
+
except Exception:
|
|
102
|
+
return "REDACTED_URL" # unparseable: drop it whole rather than guess
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _mask_param_match(match: "re.Match") -> str:
|
|
106
|
+
name = match.group(1)
|
|
107
|
+
return f"{name}=REDACTED" if is_secret_field(name) else match.group(0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def safe_error_text(exc: Any) -> str:
|
|
111
|
+
"""Exception text travels to Phoenix in the outcome report. An HTTP client
|
|
112
|
+
error embeds the URL it failed on — query secrets included — so mask
|
|
113
|
+
before it leaves the process."""
|
|
114
|
+
try:
|
|
115
|
+
text = str(exc)
|
|
116
|
+
except Exception:
|
|
117
|
+
return type(exc).__name__
|
|
118
|
+
try:
|
|
119
|
+
text = _URL_IN_TEXT.sub(_mask_url_match, text)
|
|
120
|
+
text = _PARAM_ASSIGNMENT.sub(_mask_param_match, text)
|
|
121
|
+
except Exception:
|
|
122
|
+
return type(exc).__name__
|
|
123
|
+
return text.encode("utf-8")[:ERROR_TEXT_CAP].decode("utf-8", "ignore")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def capped_response_body(raw: bytes) -> Tuple[Any, bool]:
|
|
127
|
+
truncated = len(raw) > RESPONSE_BODY_CAP
|
|
128
|
+
raw = raw[:RESPONSE_BODY_CAP]
|
|
129
|
+
try:
|
|
130
|
+
return json.loads(raw), truncated
|
|
131
|
+
except Exception:
|
|
132
|
+
text = raw.decode("utf-8", "replace")
|
|
133
|
+
# Decoding can grow the body (each undecodable byte becomes a 3-byte
|
|
134
|
+
# replacement char), and it is the text that travels — so cap the text.
|
|
135
|
+
encoded = text.encode("utf-8")
|
|
136
|
+
if len(encoded) > RESPONSE_BODY_CAP:
|
|
137
|
+
text = encoded[:RESPONSE_BODY_CAP].decode("utf-8", "ignore")
|
|
138
|
+
truncated = True
|
|
139
|
+
return text, truncated
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def heal_payload(*, trace_id: str, method: str, url: str, headers: Mapping[str, Any],
|
|
143
|
+
body: Any, status_code: int, response_body: Any, truncated: bool,
|
|
144
|
+
response_time_ms: int) -> dict:
|
|
145
|
+
return {
|
|
146
|
+
"traceId": trace_id,
|
|
147
|
+
"request": {
|
|
148
|
+
"method": method.upper(),
|
|
149
|
+
"url": safe_url(url),
|
|
150
|
+
"headers": safe_headers(headers),
|
|
151
|
+
"body": traveling_body(body), # any JSON; None when absent/huge/not JSON
|
|
152
|
+
},
|
|
153
|
+
"response": {"statusCode": status_code, "body": response_body, "truncated": truncated},
|
|
154
|
+
"responseTimeMs": response_time_ms,
|
|
155
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mnfst
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Heal failing JSON API requests on the fly.
|
|
5
|
+
Project-URL: Homepage, https://manifest.build
|
|
6
|
+
Project-URL: Repository, https://github.com/mnfst/manifest-python
|
|
7
|
+
Project-URL: Issues, https://github.com/mnfst/manifest-python/issues
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: anyio<5,>=4
|
|
10
|
+
Requires-Dist: httpx<1,>=0.24
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
13
|
+
Requires-Dist: requests>=2.31; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Manifest for Python
|
|
17
|
+
|
|
18
|
+
[](https://github.com/mnfst/manifest-python/actions/workflows/ci.yml)
|
|
19
|
+
|
|
20
|
+
Repair failed JSON API requests automatically. Works with `httpx` and `requests`, for everyday APIs and LLMs alike.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from mnfst import manifest
|
|
24
|
+
|
|
25
|
+
manifest()
|
|
26
|
+
# Keep making your API calls as usual.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Your API rejects a request → Manifest finds a repair → the SDK retries once, locally.
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
### 1. Install
|
|
34
|
+
|
|
35
|
+
Requires **Python 3.10+**:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
python -m venv .venv
|
|
39
|
+
source .venv/bin/activate
|
|
40
|
+
python -m pip install mnfst
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
On Windows, activate with `.venv\Scripts\activate` instead. The package and import name are **mnfst**. `httpx` is included; install `requests` separately if you use it.
|
|
44
|
+
|
|
45
|
+
### 2. Connect your project
|
|
46
|
+
|
|
47
|
+
Create a project in your Manifest dashboard and copy the project key shown during setup. In **Project Settings**, turn **Autofix** on to enable repairs.
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
export MNFST_KEY='your-project-key'
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The SDK defaults to `https://api.manifest.build`. For a local app running on port 5310, also set:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
export MNFST_URL='http://127.0.0.1:5310'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Your server must support the [SDK API contract](CONTRACT.md). The local app must already be running.
|
|
60
|
+
|
|
61
|
+
### 3. Initialize before your requests
|
|
62
|
+
|
|
63
|
+
Call `manifest()` once at startup. Save this as `example.py`, replacing the example endpoint and payload with your own:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import httpx
|
|
67
|
+
from mnfst import manifest, flush
|
|
68
|
+
|
|
69
|
+
manifest(
|
|
70
|
+
on_heal=lambda event: print(
|
|
71
|
+
"[manifest]", event.heal_status, event.replay_status_code
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
response = httpx.post(
|
|
77
|
+
"https://api.example.com/orders",
|
|
78
|
+
json={"limit": 500},
|
|
79
|
+
)
|
|
80
|
+
print(response.status_code, response.text)
|
|
81
|
+
finally:
|
|
82
|
+
flush(timeout=5)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Run it with `python example.py`. For an API that rejects `limit: 500` and has a matching repair, Manifest can retry with a valid limit. Repairs depend on the API error and available patches.
|
|
86
|
+
|
|
87
|
+
The same initialization covers `httpx.AsyncClient` and `requests` calls using their standard transports.
|
|
88
|
+
|
|
89
|
+
## Check that it works
|
|
90
|
+
|
|
91
|
+
Send a JSON request that your test API rejects with **400, 404 or 422**. Check the failure in your project's dashboard and the `on_heal` callback for the repair result. A successful request alone does not contact Manifest. `flush()` lets a short script wait for outcome reports before exiting.
|
|
92
|
+
|
|
93
|
+
## What to expect
|
|
94
|
+
|
|
95
|
+
- **One retry.** Manifest returns a repair; the SDK sends the corrected request directly to your API.
|
|
96
|
+
- **Original error if healing is unavailable.** A heal call can add up to 60 seconds. If a retry returns an HTTP response, that response reaches your application.
|
|
97
|
+
- **Sync and async.** Standard `httpx` transports and `requests` adapters are covered process-wide. Custom transports and `aiohttp` are not intercepted.
|
|
98
|
+
- **Retry semantics still matter.** Use idempotency keys where needed; a repeated request can repeat side effects.
|
|
99
|
+
|
|
100
|
+
## Privacy
|
|
101
|
+
|
|
102
|
+
Manifest receives failed request URLs, headers, JSON bodies and error responses. Known credential fields are masked or withheld, but nested secrets, prompts and business data can still be sent. Enable it only for traffic you permit your Manifest server to process and store.
|
|
103
|
+
|
|
104
|
+
## More
|
|
105
|
+
|
|
106
|
+
[Configuration, limits & development](docs/guide.md) · [API contract](CONTRACT.md) · [Node.js SDK](https://github.com/mnfst/manifest-node)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
mnfst/__init__.py,sha256=9qMBmHrYWaEpmZ_y-KMc1YiVkkVA2aWkMiqMb9Q0TE8,1787
|
|
2
|
+
mnfst/config.py,sha256=W0DZrFbTnr__KYHUnZNFOjd3Yc1wQNk6l5_luyG16nY,1364
|
|
3
|
+
mnfst/gate.py,sha256=HnEVwx2-z5k5XGn0k5kwcR7LWoFWvhtFOx5UWtP_tCY,1908
|
|
4
|
+
mnfst/heal_api.py,sha256=SJNsuVFQjl-ZkjqR9tAOMzY6edyUT-IVsuXoagcRO7k,8852
|
|
5
|
+
mnfst/merge.py,sha256=eLH5g69rY-rCTg57ZiQU5LsaZSeT3kUEZmO-ZAd7xrw,933
|
|
6
|
+
mnfst/outbound.py,sha256=44Sp9X8fVaxXX_2LQBc7yL5F16Q_Qx7gwuOU80Eui-k,15827
|
|
7
|
+
mnfst/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
8
|
+
mnfst/response_capture.py,sha256=S1LfrZTrg_w4P7-0uhBSbTDbwde3C3gQauyLYoZgOko,5935
|
|
9
|
+
mnfst/version.py,sha256=FmVhVCCcR0wnEsA1rjRkb7hlpSR7ysl_gyuF-lhwSMI,198
|
|
10
|
+
mnfst/wire.py,sha256=4D7ho2qHbLdgHHwdaH9BCs9YjY0DbEmVnVH_9MwEPpI,6122
|
|
11
|
+
mnfst-0.1.0.dist-info/METADATA,sha256=9mKKDePOX4AaysTf_TyUtmsJPKlQoDh_GaIPDuhLsYE,3929
|
|
12
|
+
mnfst-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
13
|
+
mnfst-0.1.0.dist-info/RECORD,,
|