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 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
@@ -0,0 +1,5 @@
1
+ __version__ = "0.1.0"
2
+
3
+ # Backward-compatible public name. The release value lives only above so Hatch,
4
+ # package metadata, and the SDK's User-Agent cannot drift independently.
5
+ VERSION = __version__
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
+ [![CI](https://github.com/mnfst/manifest-python/actions/workflows/ci.yml/badge.svg?branch=main)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any