mnfst 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mnfst-0.1.0/.github/workflows/ci.yml +25 -0
- mnfst-0.1.0/.github/workflows/publish.yml +93 -0
- mnfst-0.1.0/.gitignore +6 -0
- mnfst-0.1.0/CONTRACT.md +50 -0
- mnfst-0.1.0/PKG-INFO +106 -0
- mnfst-0.1.0/README.md +91 -0
- mnfst-0.1.0/docs/guide.md +56 -0
- mnfst-0.1.0/pyproject.toml +28 -0
- mnfst-0.1.0/src/mnfst/__init__.py +43 -0
- mnfst-0.1.0/src/mnfst/config.py +37 -0
- mnfst-0.1.0/src/mnfst/gate.py +51 -0
- mnfst-0.1.0/src/mnfst/heal_api.py +242 -0
- mnfst-0.1.0/src/mnfst/merge.py +23 -0
- mnfst-0.1.0/src/mnfst/outbound.py +368 -0
- mnfst-0.1.0/src/mnfst/py.typed +1 -0
- mnfst-0.1.0/src/mnfst/response_capture.py +169 -0
- mnfst-0.1.0/src/mnfst/version.py +5 -0
- mnfst-0.1.0/src/mnfst/wire.py +155 -0
- mnfst-0.1.0/tests/__init__.py +0 -0
- mnfst-0.1.0/tests/stub_phoenix.py +90 -0
- mnfst-0.1.0/tests/test_config.py +36 -0
- mnfst-0.1.0/tests/test_e2e.py +57 -0
- mnfst-0.1.0/tests/test_entrypoint.py +58 -0
- mnfst-0.1.0/tests/test_gate.py +27 -0
- mnfst-0.1.0/tests/test_hardening.py +134 -0
- mnfst-0.1.0/tests/test_heal_api.py +147 -0
- mnfst-0.1.0/tests/test_live_app.py +89 -0
- mnfst-0.1.0/tests/test_merge.py +36 -0
- mnfst-0.1.0/tests/test_outbound_httpx.py +387 -0
- mnfst-0.1.0/tests/test_outbound_requests.py +103 -0
- mnfst-0.1.0/tests/test_package_metadata.py +17 -0
- mnfst-0.1.0/tests/test_stub_contract.py +33 -0
- mnfst-0.1.0/tests/test_wire.py +119 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ["3.10", "3.13", "3.14"]
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
- run: python -m pip install -e ".[dev]"
|
|
24
|
+
- run: python -m pytest -q
|
|
25
|
+
- run: python -m pip wheel --no-deps . --wheel-dir dist
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
paths:
|
|
7
|
+
- src/mnfst/version.py
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: publish-mnfst
|
|
12
|
+
cancel-in-progress: false
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
detect:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
outputs:
|
|
21
|
+
version: ${{ steps.detect.outputs.version }}
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
|
24
|
+
with:
|
|
25
|
+
fetch-depth: 0
|
|
26
|
+
persist-credentials: false
|
|
27
|
+
- name: Detect version bump
|
|
28
|
+
id: detect
|
|
29
|
+
env:
|
|
30
|
+
BEFORE_SHA: ${{ github.event.before }}
|
|
31
|
+
AFTER_SHA: ${{ github.sha }}
|
|
32
|
+
MANUAL: ${{ github.event_name == 'workflow_dispatch' }}
|
|
33
|
+
run: |
|
|
34
|
+
current=$(git show "${AFTER_SHA}:src/mnfst/version.py" | sed -n 's/^__version__ = "\([^"]*\)"$/\1/p')
|
|
35
|
+
if [ -z "$current" ]; then
|
|
36
|
+
echo "::error::Could not read the current package version."
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
if [ "$MANUAL" = "true" ]; then
|
|
40
|
+
echo "version=$current" >> "$GITHUB_OUTPUT"
|
|
41
|
+
echo "Manual dispatch: publishing $current"
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then
|
|
45
|
+
previous=$(git show "${BEFORE_SHA}:src/mnfst/version.py" 2>/dev/null | sed -n 's/^__version__ = "\([^"]*\)"$/\1/p')
|
|
46
|
+
else
|
|
47
|
+
previous=""
|
|
48
|
+
fi
|
|
49
|
+
if [ -n "$previous" ] && [ "$previous" != "$current" ]; then
|
|
50
|
+
echo "version=$current" >> "$GITHUB_OUTPUT"
|
|
51
|
+
echo "Version bumped: $previous -> $current"
|
|
52
|
+
else
|
|
53
|
+
echo "version=" >> "$GITHUB_OUTPUT"
|
|
54
|
+
echo "::notice::No version change; nothing to publish."
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
build:
|
|
58
|
+
needs: detect
|
|
59
|
+
if: needs.detect.outputs.version != ''
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
steps:
|
|
62
|
+
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
|
63
|
+
with:
|
|
64
|
+
persist-credentials: false
|
|
65
|
+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
|
66
|
+
with:
|
|
67
|
+
python-version: '3.14'
|
|
68
|
+
cache: pip
|
|
69
|
+
- run: python -m pip install build twine
|
|
70
|
+
- run: python -m pip install -e '.[dev]'
|
|
71
|
+
- run: python -m pytest -q
|
|
72
|
+
- run: python -m build
|
|
73
|
+
- run: python -m twine check dist/*
|
|
74
|
+
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
75
|
+
with:
|
|
76
|
+
name: python-package-distributions
|
|
77
|
+
path: dist/
|
|
78
|
+
retention-days: 1
|
|
79
|
+
|
|
80
|
+
publish:
|
|
81
|
+
needs: build
|
|
82
|
+
runs-on: ubuntu-latest
|
|
83
|
+
environment:
|
|
84
|
+
name: pypi
|
|
85
|
+
url: https://pypi.org/p/mnfst
|
|
86
|
+
permissions:
|
|
87
|
+
id-token: write
|
|
88
|
+
steps:
|
|
89
|
+
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
|
|
90
|
+
with:
|
|
91
|
+
name: python-package-distributions
|
|
92
|
+
path: dist/
|
|
93
|
+
- uses: pypa/gh-action-pypi-publish@v1.14.2
|
mnfst-0.1.0/.gitignore
ADDED
mnfst-0.1.0/CONTRACT.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SDK / app contract
|
|
2
|
+
|
|
3
|
+
The SDK talks to the configured Manifest API using `Authorization: Bearer <project key>` and `User-Agent: mnfst-python/<version>`.
|
|
4
|
+
|
|
5
|
+
## Capture
|
|
6
|
+
|
|
7
|
+
`POST /v1/heal` receives:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"traceId": "unique-capture-id",
|
|
12
|
+
"request": {"method": "POST", "url": "https://example.com/orders", "headers": {}, "body": {"limit": 200}},
|
|
13
|
+
"response": {"statusCode": 400, "body": {"error": "limit must be at most 100"}, "truncated": false},
|
|
14
|
+
"responseTimeMs": 25
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Credential filtering and body limits are described in the README. Capture gates live in `gate.py`; the server owns repair policy.
|
|
19
|
+
|
|
20
|
+
A successful heal response may contain `status: patched|unverified`, `healAttemptId`, `operations` and `healedRequest` with `url`, `headers` or `body`. Only these two statuses authorize a retry. No patch, malformed responses and unavailable service return the original error response. HTTP 403 with `{"error":"project_disabled"}` suppresses healing for five minutes.
|
|
21
|
+
|
|
22
|
+
## Apply
|
|
23
|
+
|
|
24
|
+
A healed URL replaces the URL only within the original origin. Headers set or replace case-insensitively; null removes a header. Content length is recalculated. Objects merge using the server's healed body as the authoritative copy of fields sent to the server; withheld local credential fields are restored. Non-object JSON replaces the body. An unreadable original body needs a replacement body before it can be retried.
|
|
25
|
+
|
|
26
|
+
Each captured failure permits one retry. A retry response, including another failure, is returned to the caller. A transport failure returns the original response. Successful response streams are not eagerly consumed.
|
|
27
|
+
|
|
28
|
+
## Outcome
|
|
29
|
+
|
|
30
|
+
`PATCH /v1/heal-attempts/:id` sends exactly one of:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{"response":{"statusCode":200}}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{"response":{"statusCode":400,"body":{"error":"raw upstream error"},"truncated":false}}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{"failure":{"kind":"transport_error","message":"connection reset"}}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{"failure":{"kind":"not_attempted","message":"replay_not_attempted"}}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
HTTP status must be 200–599. Failure messages are capped at 512 UTF-8 bytes after credential filtering. HTTP status zero is not a wire status. Transport failures and unattempted retries are inconclusive evidence; neither can verify or invalidate a patch. The server determines the verdict from the raw evidence, with the first accepted report winning.
|
|
49
|
+
|
|
50
|
+
Reports are best effort, bounded, and observable through logger warnings. The SDK sends the failed retry's raw body so the app can distinguish recurrence from a newly revealed issue. It does not assert `succeeded` or `failed` itself.
|
mnfst-0.1.0/PKG-INFO
ADDED
|
@@ -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)
|
mnfst-0.1.0/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Manifest for Python
|
|
2
|
+
|
|
3
|
+
[](https://github.com/mnfst/manifest-python/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Repair failed JSON API requests automatically. Works with `httpx` and `requests`, for everyday APIs and LLMs alike.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from mnfst import manifest
|
|
9
|
+
|
|
10
|
+
manifest()
|
|
11
|
+
# Keep making your API calls as usual.
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Your API rejects a request → Manifest finds a repair → the SDK retries once, locally.
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
### 1. Install
|
|
19
|
+
|
|
20
|
+
Requires **Python 3.10+**:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
python -m venv .venv
|
|
24
|
+
source .venv/bin/activate
|
|
25
|
+
python -m pip install mnfst
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
### 2. Connect your project
|
|
31
|
+
|
|
32
|
+
Create a project in your Manifest dashboard and copy the project key shown during setup. In **Project Settings**, turn **Autofix** on to enable repairs.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
export MNFST_KEY='your-project-key'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The SDK defaults to `https://api.manifest.build`. For a local app running on port 5310, also set:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
export MNFST_URL='http://127.0.0.1:5310'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Your server must support the [SDK API contract](CONTRACT.md). The local app must already be running.
|
|
45
|
+
|
|
46
|
+
### 3. Initialize before your requests
|
|
47
|
+
|
|
48
|
+
Call `manifest()` once at startup. Save this as `example.py`, replacing the example endpoint and payload with your own:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import httpx
|
|
52
|
+
from mnfst import manifest, flush
|
|
53
|
+
|
|
54
|
+
manifest(
|
|
55
|
+
on_heal=lambda event: print(
|
|
56
|
+
"[manifest]", event.heal_status, event.replay_status_code
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
response = httpx.post(
|
|
62
|
+
"https://api.example.com/orders",
|
|
63
|
+
json={"limit": 500},
|
|
64
|
+
)
|
|
65
|
+
print(response.status_code, response.text)
|
|
66
|
+
finally:
|
|
67
|
+
flush(timeout=5)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
The same initialization covers `httpx.AsyncClient` and `requests` calls using their standard transports.
|
|
73
|
+
|
|
74
|
+
## Check that it works
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
## What to expect
|
|
79
|
+
|
|
80
|
+
- **One retry.** Manifest returns a repair; the SDK sends the corrected request directly to your API.
|
|
81
|
+
- **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.
|
|
82
|
+
- **Sync and async.** Standard `httpx` transports and `requests` adapters are covered process-wide. Custom transports and `aiohttp` are not intercepted.
|
|
83
|
+
- **Retry semantics still matter.** Use idempotency keys where needed; a repeated request can repeat side effects.
|
|
84
|
+
|
|
85
|
+
## Privacy
|
|
86
|
+
|
|
87
|
+
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.
|
|
88
|
+
|
|
89
|
+
## More
|
|
90
|
+
|
|
91
|
+
[Configuration, limits & development](docs/guide.md) · [API contract](CONTRACT.md) · [Node.js SDK](https://github.com/mnfst/manifest-node)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SDK guide
|
|
2
|
+
|
|
3
|
+
[← Quick start](../README.md)
|
|
4
|
+
|
|
5
|
+
## Configuration
|
|
6
|
+
|
|
7
|
+
Call `manifest()` once at startup. Arguments override environment variables:
|
|
8
|
+
|
|
9
|
+
| Argument | Environment | Default |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `key` | `MNFST_KEY` | Missing key disables the SDK with a warning |
|
|
12
|
+
| `url` | `MNFST_URL` | `https://api.manifest.build` |
|
|
13
|
+
| `on_heal` | — | Optional callback receiving a `HealEvent` |
|
|
14
|
+
|
|
15
|
+
Use `url="http://127.0.0.1:5310"` with a local Manifest app. The hosted default requires a deployed, compatible app. Changing configuration after initialization requires a process restart.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from mnfst import manifest, flush
|
|
19
|
+
|
|
20
|
+
manifest(on_heal=lambda event: print(event.heal_status, event.replay_status_code))
|
|
21
|
+
# Before a short-lived process exits:
|
|
22
|
+
flush(timeout=5)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Reports run in background threads. `flush` waits for outstanding reports within one total timeout; it does not guarantee delivery. Normal interpreter exit allows two seconds to flush. Failed or dropped reports emit warnings on the `mnfst` logger. Abrupt termination can lose reports.
|
|
26
|
+
|
|
27
|
+
## Behavior and limits
|
|
28
|
+
|
|
29
|
+
- Standard httpx transports and requests adapters are instrumented process-wide, including existing clients. Custom transports, aiohttp, browsers and other languages are not covered.
|
|
30
|
+
- Eligible HTTP failures are sent to Manifest; successful calls, authentication failures, rate limits and server errors pass through. A network failure before an HTTP response also passes through.
|
|
31
|
+
- Manifest selects repairs. The SDK retries at most once per captured failure. The original error response is returned if healing is unavailable, no repair can be applied, or the retry has a transport error.
|
|
32
|
+
- A successful streaming retry remains streamed. Error capture reads a bounded prefix and preserves the original response bytes for the caller. Error reads use the caller's read timeout; healing adds up to 60 seconds, and the retry uses the caller's timeout.
|
|
33
|
+
- The sync heal worker pool permits eight concurrent calls. Excess calls fail open. Timed-out workers can continue in the background within that bound. Outcome reporting permits 64 concurrent reports per reporter.
|
|
34
|
+
- Request JSON is limited to 256 KiB and depth 64. Multipart, binary, streamed, oversized and invalid JSON bodies travel as `null`; they are not generally repairable. Response metadata is limited to 64 KiB; truncated errors are reported without retry. Gzip and deflate error prefixes are decoded within that limit; unsupported content encodings provide no body evidence.
|
|
35
|
+
- Retries can repeat side effects. Use APIs with safe retry semantics and caller-managed idempotency keys. Existing credentials and idempotency headers are retained unless explicitly changed by the repair. URL repairs must stay on the same origin.
|
|
36
|
+
|
|
37
|
+
## Data sent to Manifest
|
|
38
|
+
|
|
39
|
+
Failed request URLs, headers, JSON bodies and error responses are sent to the configured server. Known credential names in query parameters and headers are masked. Credential-named **top-level** request body fields are withheld and restored for the retry.
|
|
40
|
+
|
|
41
|
+
This is not general data-loss prevention: nested fields, arbitrary secret names, personal data, prompts and response bodies may still contain sensitive content. Only enable it for traffic you permit Manifest to process and store. The server does not receive the original credential values masked by the SDK.
|
|
42
|
+
|
|
43
|
+
## Development
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
pip install -e '.[dev]'
|
|
47
|
+
pytest -q
|
|
48
|
+
# Optional: point only at a disposable app (creates a test customer/project).
|
|
49
|
+
MNFST_TEST_APP_URL=http://127.0.0.1:5310 pytest -q tests/test_live_app.py
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
CI tests Python 3.10, 3.13 and 3.14 and builds the wheel. The live app test runs locally because the app repository is private; cross-repository CI needs separate checkout credentials. Validated against app commit `9ea359279577f99e4058b7600c75889b1a2c5881`.
|
|
53
|
+
|
|
54
|
+
See [CONTRACT.md](../CONTRACT.md) for the wire protocol. Transport failures require the app's explicit `failure` outcome support; they must never be reported as HTTP success.
|
|
55
|
+
|
|
56
|
+
Adapted from [guillaumegay13/autofix-python](https://github.com/guillaumegay13/autofix-python), source commit `9a82d8235a037392826f982626f49422f3828213`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mnfst"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
description = "Heal failing JSON API requests on the fly."
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = ["httpx>=0.24,<1", "anyio>=4,<5"]
|
|
8
|
+
|
|
9
|
+
[project.urls]
|
|
10
|
+
Homepage = "https://manifest.build"
|
|
11
|
+
Repository = "https://github.com/mnfst/manifest-python"
|
|
12
|
+
Issues = "https://github.com/mnfst/manifest-python/issues"
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=8", "requests>=2.31"]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["hatchling"]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/mnfst"]
|
|
23
|
+
|
|
24
|
+
[tool.hatch.version]
|
|
25
|
+
path = "src/mnfst/version.py"
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
@@ -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)
|
|
@@ -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
|
+
)
|
|
@@ -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
|