mnm-sdk 0.1.1__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.
- mnm_sdk-0.1.1/LICENSE +21 -0
- mnm_sdk-0.1.1/PKG-INFO +73 -0
- mnm_sdk-0.1.1/README.md +58 -0
- mnm_sdk-0.1.1/mnm_sdk/__init__.py +6 -0
- mnm_sdk-0.1.1/mnm_sdk/monitor.py +212 -0
- mnm_sdk-0.1.1/mnm_sdk/transport.py +76 -0
- mnm_sdk-0.1.1/mnm_sdk.egg-info/PKG-INFO +73 -0
- mnm_sdk-0.1.1/mnm_sdk.egg-info/SOURCES.txt +13 -0
- mnm_sdk-0.1.1/mnm_sdk.egg-info/dependency_links.txt +1 -0
- mnm_sdk-0.1.1/mnm_sdk.egg-info/requires.txt +5 -0
- mnm_sdk-0.1.1/mnm_sdk.egg-info/top_level.txt +1 -0
- mnm_sdk-0.1.1/pyproject.toml +24 -0
- mnm_sdk-0.1.1/setup.cfg +4 -0
- mnm_sdk-0.1.1/tests/test_monitor.py +270 -0
- mnm_sdk-0.1.1/tests/test_transport.py +129 -0
mnm_sdk-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sohan Narewadi and Radhesh Nair
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mnm_sdk-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mnm-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: MNM client: log model predictions asynchronously, without slowing inference
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/Sohan-Narewadi/MNM
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
13
|
+
Requires-Dist: black>=24; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# sdk/ — MNM Python client
|
|
17
|
+
|
|
18
|
+
A tiny library a model service imports to log predictions to MNM **without slowing down inference**.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
```bash
|
|
22
|
+
pip install mnm-sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
```python
|
|
27
|
+
from mnm_sdk import Monitor
|
|
28
|
+
|
|
29
|
+
monitor = Monitor(api_url="http://backend:8000", model="churn", version="3")
|
|
30
|
+
|
|
31
|
+
@app.post("/predict")
|
|
32
|
+
def predict(req: Request):
|
|
33
|
+
t0 = time.perf_counter()
|
|
34
|
+
y = model.predict(req.features)
|
|
35
|
+
monitor.log(features=req.features, output=y, latency_ms=(time.perf_counter() - t0) * 1000)
|
|
36
|
+
return y
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## How it behaves
|
|
40
|
+
- `log()` only appends to an in-memory bounded queue and returns: it never waits on the network and never raises, whatever is passed in.
|
|
41
|
+
- A background daemon thread sends batches to `POST /v1/ingest` when `batch_size` events are queued or every `flush_interval` seconds, whichever comes first.
|
|
42
|
+
- A failed batch is retried with exponential backoff (`max_retries`, `retry_backoff`). Each event carries its own `request_id` and `ts`, and ingestion is idempotent on those, so a retry never stores an event twice. A batch the server rejects as invalid (4xx) is not retried.
|
|
43
|
+
- `NaN` and `Infinity` (for example a missing feature from pandas or numpy) are sent as `null`, because JSON has no such numbers.
|
|
44
|
+
- If the queue is full, the **oldest** event is dropped and counted. Nothing blocks.
|
|
45
|
+
- `close()` runs at interpreter exit (`atexit`) and sends what is still queued.
|
|
46
|
+
- `monitor.stats` reports `logged`, `sent`, `dropped`, `failed` and `queued`.
|
|
47
|
+
- No dependencies outside the standard library. Python 3.9+.
|
|
48
|
+
|
|
49
|
+
| Option | Default | Meaning |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `batch_size` | 100 | Send as soon as this many events are queued |
|
|
52
|
+
| `flush_interval` | 1.0 s | Send whatever is queued at least this often |
|
|
53
|
+
| `max_queue` | 10 000 | Queue capacity; beyond it the oldest event is dropped |
|
|
54
|
+
| `max_retries` | 3 | Extra attempts for a batch that failed |
|
|
55
|
+
| `retry_backoff` | 0.5 s | Wait before the first retry; doubles each time |
|
|
56
|
+
| `timeout` | 5.0 s | HTTP timeout per request |
|
|
57
|
+
|
|
58
|
+
`log()` also accepts `request_id=`, `label=` and `ts=`. The event format is the ingest contract in [`docs/api-spec.md`](../docs/api-spec.md).
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
```bash
|
|
62
|
+
cd sdk
|
|
63
|
+
pip install -e ".[dev]"
|
|
64
|
+
pytest # no network and no backend needed
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Layout
|
|
68
|
+
```
|
|
69
|
+
sdk/
|
|
70
|
+
├── mnm_sdk/__init__.py, monitor.py, transport.py
|
|
71
|
+
├── tests/
|
|
72
|
+
└── pyproject.toml
|
|
73
|
+
```
|
mnm_sdk-0.1.1/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# sdk/ — MNM Python client
|
|
2
|
+
|
|
3
|
+
A tiny library a model service imports to log predictions to MNM **without slowing down inference**.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
```bash
|
|
7
|
+
pip install mnm-sdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
```python
|
|
12
|
+
from mnm_sdk import Monitor
|
|
13
|
+
|
|
14
|
+
monitor = Monitor(api_url="http://backend:8000", model="churn", version="3")
|
|
15
|
+
|
|
16
|
+
@app.post("/predict")
|
|
17
|
+
def predict(req: Request):
|
|
18
|
+
t0 = time.perf_counter()
|
|
19
|
+
y = model.predict(req.features)
|
|
20
|
+
monitor.log(features=req.features, output=y, latency_ms=(time.perf_counter() - t0) * 1000)
|
|
21
|
+
return y
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## How it behaves
|
|
25
|
+
- `log()` only appends to an in-memory bounded queue and returns: it never waits on the network and never raises, whatever is passed in.
|
|
26
|
+
- A background daemon thread sends batches to `POST /v1/ingest` when `batch_size` events are queued or every `flush_interval` seconds, whichever comes first.
|
|
27
|
+
- A failed batch is retried with exponential backoff (`max_retries`, `retry_backoff`). Each event carries its own `request_id` and `ts`, and ingestion is idempotent on those, so a retry never stores an event twice. A batch the server rejects as invalid (4xx) is not retried.
|
|
28
|
+
- `NaN` and `Infinity` (for example a missing feature from pandas or numpy) are sent as `null`, because JSON has no such numbers.
|
|
29
|
+
- If the queue is full, the **oldest** event is dropped and counted. Nothing blocks.
|
|
30
|
+
- `close()` runs at interpreter exit (`atexit`) and sends what is still queued.
|
|
31
|
+
- `monitor.stats` reports `logged`, `sent`, `dropped`, `failed` and `queued`.
|
|
32
|
+
- No dependencies outside the standard library. Python 3.9+.
|
|
33
|
+
|
|
34
|
+
| Option | Default | Meaning |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `batch_size` | 100 | Send as soon as this many events are queued |
|
|
37
|
+
| `flush_interval` | 1.0 s | Send whatever is queued at least this often |
|
|
38
|
+
| `max_queue` | 10 000 | Queue capacity; beyond it the oldest event is dropped |
|
|
39
|
+
| `max_retries` | 3 | Extra attempts for a batch that failed |
|
|
40
|
+
| `retry_backoff` | 0.5 s | Wait before the first retry; doubles each time |
|
|
41
|
+
| `timeout` | 5.0 s | HTTP timeout per request |
|
|
42
|
+
|
|
43
|
+
`log()` also accepts `request_id=`, `label=` and `ts=`. The event format is the ingest contract in [`docs/api-spec.md`](../docs/api-spec.md).
|
|
44
|
+
|
|
45
|
+
## Development
|
|
46
|
+
```bash
|
|
47
|
+
cd sdk
|
|
48
|
+
pip install -e ".[dev]"
|
|
49
|
+
pytest # no network and no backend needed
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Layout
|
|
53
|
+
```
|
|
54
|
+
sdk/
|
|
55
|
+
├── mnm_sdk/__init__.py, monitor.py, transport.py
|
|
56
|
+
├── tests/
|
|
57
|
+
└── pyproject.toml
|
|
58
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""MNM SDK: log model predictions to MNM without slowing down inference."""
|
|
2
|
+
|
|
3
|
+
from mnm_sdk.monitor import Monitor, Stats
|
|
4
|
+
from mnm_sdk.transport import HttpTransport, Transport, TransportError
|
|
5
|
+
|
|
6
|
+
__all__ = ["HttpTransport", "Monitor", "Stats", "Transport", "TransportError"]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Monitor: the object a model service uses to log predictions.
|
|
2
|
+
|
|
3
|
+
`log()` only appends to a bounded in-memory queue. A background thread sends
|
|
4
|
+
the queue to the MNM ingestion API in batches. Nothing in here may slow down
|
|
5
|
+
or break the caller's prediction path: `log()` never blocks on the network
|
|
6
|
+
and never raises.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
import uuid
|
|
15
|
+
from collections import deque
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from mnm_sdk.transport import HttpTransport, Transport, TransportError
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("mnm")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Stats:
|
|
28
|
+
"""Counters since the Monitor was created."""
|
|
29
|
+
|
|
30
|
+
logged: int # events accepted by log()
|
|
31
|
+
sent: int # events delivered to the ingestion API
|
|
32
|
+
dropped: int # events discarded before sending (queue full, invalid, closed)
|
|
33
|
+
failed: int # events given up on after the retries ran out
|
|
34
|
+
queued: int # events waiting in the queue right now
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Monitor:
|
|
38
|
+
"""Logs predictions of one model version to MNM in the background.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
api_url: Base URL of the MNM backend, e.g. ``http://backend:8000``.
|
|
42
|
+
model: Model name.
|
|
43
|
+
version: Model version.
|
|
44
|
+
batch_size: Send as soon as this many events are queued.
|
|
45
|
+
flush_interval: Send whatever is queued at least this often (seconds).
|
|
46
|
+
max_queue: Queue capacity. When full, the oldest event is dropped.
|
|
47
|
+
max_retries: Extra attempts for a batch that failed to send.
|
|
48
|
+
retry_backoff: Wait before the first retry (seconds); doubles each time.
|
|
49
|
+
timeout: HTTP timeout per request (seconds).
|
|
50
|
+
transport: Replace the HTTP transport (used by tests).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
api_url: str,
|
|
56
|
+
model: str,
|
|
57
|
+
version: str | int,
|
|
58
|
+
*,
|
|
59
|
+
batch_size: int = 100,
|
|
60
|
+
flush_interval: float = 1.0,
|
|
61
|
+
max_queue: int = 10_000,
|
|
62
|
+
max_retries: int = 3,
|
|
63
|
+
retry_backoff: float = 0.5,
|
|
64
|
+
timeout: float = 5.0,
|
|
65
|
+
transport: Transport | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._model = model
|
|
68
|
+
self._version = str(version)
|
|
69
|
+
self._batch_size = max(1, batch_size)
|
|
70
|
+
self._flush_interval = flush_interval
|
|
71
|
+
self._max_queue = max(1, max_queue)
|
|
72
|
+
self._max_retries = max(0, max_retries)
|
|
73
|
+
self._retry_backoff = retry_backoff
|
|
74
|
+
self._transport = transport or HttpTransport(api_url, timeout=timeout)
|
|
75
|
+
|
|
76
|
+
self._queue: deque[dict[str, Any]] = deque()
|
|
77
|
+
self._lock = threading.Lock()
|
|
78
|
+
self._drained = threading.Condition(self._lock)
|
|
79
|
+
self._wake = threading.Event()
|
|
80
|
+
self._closing = threading.Event()
|
|
81
|
+
self._in_flight = 0
|
|
82
|
+
self._logged = self._sent = self._dropped = self._failed = 0
|
|
83
|
+
|
|
84
|
+
self._thread = threading.Thread(
|
|
85
|
+
target=self._run, name="mnm-monitor", daemon=True
|
|
86
|
+
)
|
|
87
|
+
self._thread.start()
|
|
88
|
+
atexit.register(self.close)
|
|
89
|
+
|
|
90
|
+
# -- called on the prediction path -------------------------------------
|
|
91
|
+
|
|
92
|
+
def log(
|
|
93
|
+
self,
|
|
94
|
+
features: Mapping[str, Any],
|
|
95
|
+
output: Any,
|
|
96
|
+
latency_ms: float,
|
|
97
|
+
*,
|
|
98
|
+
request_id: str | None = None,
|
|
99
|
+
label: Any = None,
|
|
100
|
+
ts: datetime | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Queue one prediction. Returns immediately and never raises."""
|
|
103
|
+
try:
|
|
104
|
+
event: dict[str, Any] = {
|
|
105
|
+
"model": self._model,
|
|
106
|
+
"version": self._version,
|
|
107
|
+
"request_id": request_id or uuid.uuid4().hex,
|
|
108
|
+
"ts": (ts or datetime.now(timezone.utc)).isoformat(),
|
|
109
|
+
"features": dict(features),
|
|
110
|
+
"output": output,
|
|
111
|
+
"latency_ms": float(latency_ms),
|
|
112
|
+
}
|
|
113
|
+
if label is not None:
|
|
114
|
+
event["label"] = label
|
|
115
|
+
except Exception:
|
|
116
|
+
with self._lock:
|
|
117
|
+
self._dropped += 1
|
|
118
|
+
logger.debug("mnm: dropped an event that could not be built", exc_info=True)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
with self._lock:
|
|
122
|
+
if self._closing.is_set():
|
|
123
|
+
self._dropped += 1
|
|
124
|
+
return
|
|
125
|
+
if len(self._queue) >= self._max_queue:
|
|
126
|
+
self._queue.popleft()
|
|
127
|
+
self._dropped += 1
|
|
128
|
+
self._queue.append(event)
|
|
129
|
+
self._logged += 1
|
|
130
|
+
batch_ready = len(self._queue) >= self._batch_size
|
|
131
|
+
if batch_ready:
|
|
132
|
+
self._wake.set()
|
|
133
|
+
|
|
134
|
+
# -- control -------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def stats(self) -> Stats:
|
|
138
|
+
with self._lock:
|
|
139
|
+
return Stats(
|
|
140
|
+
logged=self._logged,
|
|
141
|
+
sent=self._sent,
|
|
142
|
+
dropped=self._dropped,
|
|
143
|
+
failed=self._failed,
|
|
144
|
+
queued=len(self._queue),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def flush(self, timeout: float | None = None) -> bool:
|
|
148
|
+
"""Block until everything logged so far was sent or given up on.
|
|
149
|
+
|
|
150
|
+
Returns False if that did not happen within `timeout` seconds.
|
|
151
|
+
"""
|
|
152
|
+
self._wake.set()
|
|
153
|
+
with self._drained:
|
|
154
|
+
return self._drained.wait_for(
|
|
155
|
+
lambda: not self._queue and not self._in_flight, timeout
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def close(self, timeout: float = 5.0) -> None:
|
|
159
|
+
"""Send what is queued, then stop the background thread. Idempotent."""
|
|
160
|
+
self._closing.set()
|
|
161
|
+
self._wake.set()
|
|
162
|
+
self._thread.join(timeout)
|
|
163
|
+
|
|
164
|
+
# -- background thread ---------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def _run(self) -> None:
|
|
167
|
+
while True:
|
|
168
|
+
self._wake.wait(self._flush_interval)
|
|
169
|
+
self._wake.clear()
|
|
170
|
+
self._drain()
|
|
171
|
+
if self._closing.is_set():
|
|
172
|
+
self._drain() # anything logged while the last batch was sending
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
def _drain(self) -> None:
|
|
176
|
+
while True:
|
|
177
|
+
with self._lock:
|
|
178
|
+
if not self._queue:
|
|
179
|
+
self._drained.notify_all()
|
|
180
|
+
return
|
|
181
|
+
size = min(self._batch_size, len(self._queue))
|
|
182
|
+
batch = [self._queue.popleft() for _ in range(size)]
|
|
183
|
+
self._in_flight = size
|
|
184
|
+
delivered = self._send(batch)
|
|
185
|
+
with self._lock:
|
|
186
|
+
self._in_flight = 0
|
|
187
|
+
if delivered:
|
|
188
|
+
self._sent += len(batch)
|
|
189
|
+
else:
|
|
190
|
+
self._failed += len(batch)
|
|
191
|
+
self._drained.notify_all()
|
|
192
|
+
|
|
193
|
+
def _send(self, batch: list[dict[str, Any]]) -> bool:
|
|
194
|
+
"""Try to deliver one batch, retrying with backoff. Never raises."""
|
|
195
|
+
delay = self._retry_backoff
|
|
196
|
+
for attempt in range(self._max_retries + 1):
|
|
197
|
+
try:
|
|
198
|
+
self._transport.send(batch)
|
|
199
|
+
return True
|
|
200
|
+
except TransportError as error:
|
|
201
|
+
reason: Exception = error
|
|
202
|
+
if not error.retryable:
|
|
203
|
+
break
|
|
204
|
+
# A bug in a transport must not kill the logging thread.
|
|
205
|
+
except Exception as error: # noqa: BLE001
|
|
206
|
+
reason = error
|
|
207
|
+
if attempt < self._max_retries:
|
|
208
|
+
# Returns early when close() is called: do not delay shutdown.
|
|
209
|
+
self._closing.wait(delay)
|
|
210
|
+
delay *= 2
|
|
211
|
+
logger.warning("mnm: gave up on %d events: %s", len(batch), reason)
|
|
212
|
+
return False
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""How batches leave the process: HTTP POST to the MNM ingestion API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
INGEST_PATH = "/v1/ingest"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TransportError(Exception):
|
|
14
|
+
"""A batch could not be delivered.
|
|
15
|
+
|
|
16
|
+
`retryable` is False when sending the same batch again cannot succeed
|
|
17
|
+
(the server rejected it as invalid).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, *, retryable: bool = True) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.retryable = retryable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Transport(Protocol):
|
|
26
|
+
def send(self, events: list[dict[str, Any]]) -> None:
|
|
27
|
+
"""Deliver one batch or raise TransportError."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _json_default(value: Any) -> Any:
|
|
31
|
+
"""Encode values `json` does not know, e.g. numpy scalars and arrays."""
|
|
32
|
+
for method in ("item", "tolist"):
|
|
33
|
+
convert = getattr(value, method, None)
|
|
34
|
+
if callable(convert):
|
|
35
|
+
return convert()
|
|
36
|
+
return str(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def encode(events: list[dict[str, Any]]) -> bytes:
|
|
40
|
+
"""Serialise a batch as strict JSON.
|
|
41
|
+
|
|
42
|
+
NaN and +/-Infinity are not JSON. Python would write them as bare tokens,
|
|
43
|
+
which the ingestion database rejects for the whole batch, so they are sent
|
|
44
|
+
as null (a missing value). The round trip also catches the ones that only
|
|
45
|
+
appear after numpy values have been converted.
|
|
46
|
+
"""
|
|
47
|
+
loose = json.dumps(events, default=_json_default)
|
|
48
|
+
if "NaN" not in loose and "Infinity" not in loose:
|
|
49
|
+
return loose.encode("utf-8")
|
|
50
|
+
strict = json.loads(loose, parse_constant=lambda _token: None)
|
|
51
|
+
return json.dumps(strict, allow_nan=False).encode("utf-8")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HttpTransport:
|
|
55
|
+
def __init__(self, api_url: str, *, timeout: float = 5.0) -> None:
|
|
56
|
+
self._url = api_url.rstrip("/") + INGEST_PATH
|
|
57
|
+
self._timeout = timeout
|
|
58
|
+
|
|
59
|
+
def send(self, events: list[dict[str, Any]]) -> None:
|
|
60
|
+
request = urllib.request.Request(
|
|
61
|
+
self._url,
|
|
62
|
+
data=encode(events),
|
|
63
|
+
headers={"Content-Type": "application/json"},
|
|
64
|
+
method="POST",
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
68
|
+
response.read()
|
|
69
|
+
except urllib.error.HTTPError as error:
|
|
70
|
+
# 4xx means the batch itself is wrong, except "slow down" answers.
|
|
71
|
+
retryable = error.code >= 500 or error.code in (408, 429)
|
|
72
|
+
raise TransportError(
|
|
73
|
+
f"MNM ingest answered HTTP {error.code}", retryable=retryable
|
|
74
|
+
) from error
|
|
75
|
+
except OSError as error: # URLError, timeouts, refused connections
|
|
76
|
+
raise TransportError(f"MNM ingest unreachable: {error}") from error
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mnm-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: MNM client: log model predictions asynchronously, without slowing inference
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/Sohan-Narewadi/MNM
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
13
|
+
Requires-Dist: black>=24; extra == "dev"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# sdk/ — MNM Python client
|
|
17
|
+
|
|
18
|
+
A tiny library a model service imports to log predictions to MNM **without slowing down inference**.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
```bash
|
|
22
|
+
pip install mnm-sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
```python
|
|
27
|
+
from mnm_sdk import Monitor
|
|
28
|
+
|
|
29
|
+
monitor = Monitor(api_url="http://backend:8000", model="churn", version="3")
|
|
30
|
+
|
|
31
|
+
@app.post("/predict")
|
|
32
|
+
def predict(req: Request):
|
|
33
|
+
t0 = time.perf_counter()
|
|
34
|
+
y = model.predict(req.features)
|
|
35
|
+
monitor.log(features=req.features, output=y, latency_ms=(time.perf_counter() - t0) * 1000)
|
|
36
|
+
return y
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## How it behaves
|
|
40
|
+
- `log()` only appends to an in-memory bounded queue and returns: it never waits on the network and never raises, whatever is passed in.
|
|
41
|
+
- A background daemon thread sends batches to `POST /v1/ingest` when `batch_size` events are queued or every `flush_interval` seconds, whichever comes first.
|
|
42
|
+
- A failed batch is retried with exponential backoff (`max_retries`, `retry_backoff`). Each event carries its own `request_id` and `ts`, and ingestion is idempotent on those, so a retry never stores an event twice. A batch the server rejects as invalid (4xx) is not retried.
|
|
43
|
+
- `NaN` and `Infinity` (for example a missing feature from pandas or numpy) are sent as `null`, because JSON has no such numbers.
|
|
44
|
+
- If the queue is full, the **oldest** event is dropped and counted. Nothing blocks.
|
|
45
|
+
- `close()` runs at interpreter exit (`atexit`) and sends what is still queued.
|
|
46
|
+
- `monitor.stats` reports `logged`, `sent`, `dropped`, `failed` and `queued`.
|
|
47
|
+
- No dependencies outside the standard library. Python 3.9+.
|
|
48
|
+
|
|
49
|
+
| Option | Default | Meaning |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `batch_size` | 100 | Send as soon as this many events are queued |
|
|
52
|
+
| `flush_interval` | 1.0 s | Send whatever is queued at least this often |
|
|
53
|
+
| `max_queue` | 10 000 | Queue capacity; beyond it the oldest event is dropped |
|
|
54
|
+
| `max_retries` | 3 | Extra attempts for a batch that failed |
|
|
55
|
+
| `retry_backoff` | 0.5 s | Wait before the first retry; doubles each time |
|
|
56
|
+
| `timeout` | 5.0 s | HTTP timeout per request |
|
|
57
|
+
|
|
58
|
+
`log()` also accepts `request_id=`, `label=` and `ts=`. The event format is the ingest contract in [`docs/api-spec.md`](../docs/api-spec.md).
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
```bash
|
|
62
|
+
cd sdk
|
|
63
|
+
pip install -e ".[dev]"
|
|
64
|
+
pytest # no network and no backend needed
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Layout
|
|
68
|
+
```
|
|
69
|
+
sdk/
|
|
70
|
+
├── mnm_sdk/__init__.py, monitor.py, transport.py
|
|
71
|
+
├── tests/
|
|
72
|
+
└── pyproject.toml
|
|
73
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
mnm_sdk/__init__.py
|
|
5
|
+
mnm_sdk/monitor.py
|
|
6
|
+
mnm_sdk/transport.py
|
|
7
|
+
mnm_sdk.egg-info/PKG-INFO
|
|
8
|
+
mnm_sdk.egg-info/SOURCES.txt
|
|
9
|
+
mnm_sdk.egg-info/dependency_links.txt
|
|
10
|
+
mnm_sdk.egg-info/requires.txt
|
|
11
|
+
mnm_sdk.egg-info/top_level.txt
|
|
12
|
+
tests/test_monitor.py
|
|
13
|
+
tests/test_transport.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mnm_sdk
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mnm-sdk"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "MNM client: log model predictions asynchronously, without slowing inference"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Repository = "https://github.com/Sohan-Narewadi/MNM"
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["pytest>=8", "ruff>=0.6", "black>=24"]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["mnm_sdk*"]
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
testpaths = ["tests"]
|
mnm_sdk-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Monitor behaviour, tested through a fake transport (no network)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from mnm_sdk import Monitor, TransportError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FakeTransport:
|
|
17
|
+
"""Records every batch. Can be told to fail or to block."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self.batches: list[list[dict[str, Any]]] = []
|
|
21
|
+
self.attempts = 0
|
|
22
|
+
self.failures: list[Exception] = []
|
|
23
|
+
self.gate = threading.Event()
|
|
24
|
+
self.gate.set()
|
|
25
|
+
|
|
26
|
+
def send(self, events: list[dict[str, Any]]) -> None:
|
|
27
|
+
self.gate.wait(timeout=10)
|
|
28
|
+
self.attempts += 1
|
|
29
|
+
if self.failures:
|
|
30
|
+
raise self.failures.pop(0)
|
|
31
|
+
self.batches.append(events)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def events(self) -> list[dict[str, Any]]:
|
|
35
|
+
return [event for batch in self.batches for event in batch]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def transport() -> FakeTransport:
|
|
40
|
+
return FakeTransport()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def make_monitor(transport: FakeTransport) -> Iterator[Any]:
|
|
45
|
+
monitors: list[Monitor] = []
|
|
46
|
+
|
|
47
|
+
def factory(**options: Any) -> Monitor:
|
|
48
|
+
options.setdefault("retry_backoff", 0.01)
|
|
49
|
+
monitor = Monitor(
|
|
50
|
+
"http://mnm.invalid",
|
|
51
|
+
model="churn",
|
|
52
|
+
version="3",
|
|
53
|
+
transport=transport,
|
|
54
|
+
**options,
|
|
55
|
+
)
|
|
56
|
+
monitors.append(monitor)
|
|
57
|
+
return monitor
|
|
58
|
+
|
|
59
|
+
yield factory
|
|
60
|
+
transport.gate.set()
|
|
61
|
+
for monitor in monitors:
|
|
62
|
+
monitor.close(timeout=2)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def log_n(monitor: Monitor, n: int) -> None:
|
|
66
|
+
for i in range(n):
|
|
67
|
+
monitor.log(features={"i": i}, output=i, latency_ms=1.0)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_logged_event_reaches_the_transport_in_the_ingest_format(
|
|
71
|
+
make_monitor: Any, transport: FakeTransport
|
|
72
|
+
) -> None:
|
|
73
|
+
monitor = make_monitor()
|
|
74
|
+
|
|
75
|
+
monitor.log(features={"age": 41, "plan": "pro"}, output=0.83, latency_ms=3.2)
|
|
76
|
+
assert monitor.flush(timeout=2)
|
|
77
|
+
|
|
78
|
+
(event,) = transport.events
|
|
79
|
+
assert event["model"] == "churn"
|
|
80
|
+
assert event["version"] == "3"
|
|
81
|
+
assert event["features"] == {"age": 41, "plan": "pro"}
|
|
82
|
+
assert event["output"] == 0.83
|
|
83
|
+
assert event["latency_ms"] == 3.2
|
|
84
|
+
assert len(event["request_id"]) >= 16
|
|
85
|
+
assert datetime.fromisoformat(event["ts"]).tzinfo is not None
|
|
86
|
+
assert "label" not in event
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_caller_supplied_request_id_and_label_are_kept(
|
|
90
|
+
make_monitor: Any, transport: FakeTransport
|
|
91
|
+
) -> None:
|
|
92
|
+
monitor = make_monitor()
|
|
93
|
+
|
|
94
|
+
monitor.log(features={}, output=1, latency_ms=0.5, request_id="abc", label=1)
|
|
95
|
+
monitor.flush(timeout=2)
|
|
96
|
+
|
|
97
|
+
assert transport.events[0]["request_id"] == "abc"
|
|
98
|
+
assert transport.events[0]["label"] == 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_events_are_sent_in_batches_of_batch_size(
|
|
102
|
+
make_monitor: Any, transport: FakeTransport
|
|
103
|
+
) -> None:
|
|
104
|
+
transport.gate.clear()
|
|
105
|
+
monitor = make_monitor(batch_size=10, flush_interval=60)
|
|
106
|
+
|
|
107
|
+
log_n(monitor, 25)
|
|
108
|
+
transport.gate.set()
|
|
109
|
+
monitor.flush(timeout=2)
|
|
110
|
+
|
|
111
|
+
assert [len(batch) for batch in transport.batches] == [10, 10, 5]
|
|
112
|
+
assert [event["output"] for event in transport.events] == list(range(25))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_a_partial_batch_is_sent_after_the_flush_interval(
|
|
116
|
+
make_monitor: Any, transport: FakeTransport
|
|
117
|
+
) -> None:
|
|
118
|
+
monitor = make_monitor(batch_size=100, flush_interval=0.05)
|
|
119
|
+
|
|
120
|
+
log_n(monitor, 3)
|
|
121
|
+
|
|
122
|
+
deadline = time.monotonic() + 2
|
|
123
|
+
while not transport.events and time.monotonic() < deadline:
|
|
124
|
+
time.sleep(0.01)
|
|
125
|
+
assert len(transport.events) == 3
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_log_does_not_wait_for_a_stuck_backend(
|
|
129
|
+
make_monitor: Any, transport: FakeTransport
|
|
130
|
+
) -> None:
|
|
131
|
+
transport.gate.clear() # every send now hangs
|
|
132
|
+
monitor = make_monitor(batch_size=10, flush_interval=0.01)
|
|
133
|
+
|
|
134
|
+
started = time.perf_counter()
|
|
135
|
+
log_n(monitor, 1000)
|
|
136
|
+
elapsed = time.perf_counter() - started
|
|
137
|
+
|
|
138
|
+
assert elapsed < 0.5
|
|
139
|
+
assert transport.batches == []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_a_full_queue_drops_the_oldest_events_and_counts_them(
|
|
143
|
+
make_monitor: Any, transport: FakeTransport
|
|
144
|
+
) -> None:
|
|
145
|
+
transport.gate.clear()
|
|
146
|
+
monitor = make_monitor(batch_size=5, flush_interval=0.01, max_queue=10)
|
|
147
|
+
monitor.log(features={}, output="first", latency_ms=1.0)
|
|
148
|
+
deadline = time.monotonic() + 2
|
|
149
|
+
while monitor.stats.queued and time.monotonic() < deadline:
|
|
150
|
+
time.sleep(0.005) # wait until the worker holds "first" and hangs
|
|
151
|
+
|
|
152
|
+
log_n(monitor, 15)
|
|
153
|
+
assert monitor.stats.dropped == 5
|
|
154
|
+
transport.gate.set()
|
|
155
|
+
monitor.flush(timeout=2)
|
|
156
|
+
|
|
157
|
+
outputs = [event["output"] for event in transport.events]
|
|
158
|
+
assert outputs == ["first", *range(5, 15)]
|
|
159
|
+
assert monitor.stats.sent == 11
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_a_failed_send_is_retried_with_the_same_events(
|
|
163
|
+
make_monitor: Any, transport: FakeTransport
|
|
164
|
+
) -> None:
|
|
165
|
+
transport.failures = [TransportError("down"), TransportError("still down")]
|
|
166
|
+
monitor = make_monitor(max_retries=3)
|
|
167
|
+
|
|
168
|
+
monitor.log(features={}, output=1, latency_ms=1.0, request_id="only")
|
|
169
|
+
monitor.flush(timeout=2)
|
|
170
|
+
|
|
171
|
+
assert transport.attempts == 3
|
|
172
|
+
assert [event["request_id"] for event in transport.events] == ["only"]
|
|
173
|
+
assert monitor.stats.sent == 1
|
|
174
|
+
assert monitor.stats.failed == 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_a_batch_is_given_up_after_max_retries_and_counted(
|
|
178
|
+
make_monitor: Any, transport: FakeTransport
|
|
179
|
+
) -> None:
|
|
180
|
+
transport.failures = [TransportError("down")] * 3
|
|
181
|
+
monitor = make_monitor(max_retries=2)
|
|
182
|
+
|
|
183
|
+
monitor.log(features={}, output="lost", latency_ms=1.0)
|
|
184
|
+
monitor.flush(timeout=2)
|
|
185
|
+
monitor.log(features={}, output="kept", latency_ms=1.0)
|
|
186
|
+
monitor.flush(timeout=2)
|
|
187
|
+
|
|
188
|
+
assert transport.attempts == 4 # 1 try + 2 retries, then the next batch
|
|
189
|
+
assert [event["output"] for event in transport.events] == ["kept"]
|
|
190
|
+
assert monitor.stats.failed == 1
|
|
191
|
+
assert monitor.stats.sent == 1
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_a_rejected_batch_is_not_retried(
|
|
195
|
+
make_monitor: Any, transport: FakeTransport
|
|
196
|
+
) -> None:
|
|
197
|
+
transport.failures = [TransportError("422 invalid", retryable=False)]
|
|
198
|
+
monitor = make_monitor(max_retries=5)
|
|
199
|
+
|
|
200
|
+
monitor.log(features={}, output=1, latency_ms=1.0)
|
|
201
|
+
monitor.flush(timeout=2)
|
|
202
|
+
|
|
203
|
+
assert transport.attempts == 1
|
|
204
|
+
assert monitor.stats.failed == 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_an_unexpected_transport_crash_does_not_kill_logging(
|
|
208
|
+
make_monitor: Any, transport: FakeTransport
|
|
209
|
+
) -> None:
|
|
210
|
+
transport.failures = [RuntimeError("bug in transport")]
|
|
211
|
+
monitor = make_monitor(max_retries=0)
|
|
212
|
+
|
|
213
|
+
monitor.log(features={}, output="lost", latency_ms=1.0)
|
|
214
|
+
monitor.flush(timeout=2)
|
|
215
|
+
monitor.log(features={}, output="kept", latency_ms=1.0)
|
|
216
|
+
monitor.flush(timeout=2)
|
|
217
|
+
|
|
218
|
+
assert [event["output"] for event in transport.events] == ["kept"]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_log_never_raises_on_bad_arguments(
|
|
222
|
+
make_monitor: Any, transport: FakeTransport
|
|
223
|
+
) -> None:
|
|
224
|
+
monitor = make_monitor()
|
|
225
|
+
|
|
226
|
+
monitor.log(features=None, output=1, latency_ms=1.0) # type: ignore[arg-type]
|
|
227
|
+
monitor.log(features=object(), output=1, latency_ms="x") # type: ignore[arg-type]
|
|
228
|
+
monitor.log(features={"ok": 1}, output=1, latency_ms=1.0)
|
|
229
|
+
monitor.flush(timeout=2)
|
|
230
|
+
|
|
231
|
+
assert [event["features"] for event in transport.events] == [{"ok": 1}]
|
|
232
|
+
assert monitor.stats.dropped == 2
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_later_changes_to_the_features_dict_do_not_leak_into_the_event(
|
|
236
|
+
make_monitor: Any, transport: FakeTransport
|
|
237
|
+
) -> None:
|
|
238
|
+
transport.gate.clear()
|
|
239
|
+
monitor = make_monitor()
|
|
240
|
+
features = {"age": 41}
|
|
241
|
+
|
|
242
|
+
monitor.log(features=features, output=1, latency_ms=1.0)
|
|
243
|
+
features["age"] = 99
|
|
244
|
+
transport.gate.set()
|
|
245
|
+
monitor.flush(timeout=2)
|
|
246
|
+
|
|
247
|
+
assert transport.events[0]["features"] == {"age": 41}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_close_sends_what_is_still_queued(
|
|
251
|
+
make_monitor: Any, transport: FakeTransport
|
|
252
|
+
) -> None:
|
|
253
|
+
monitor = make_monitor(batch_size=100, flush_interval=60)
|
|
254
|
+
log_n(monitor, 7)
|
|
255
|
+
|
|
256
|
+
monitor.close(timeout=2)
|
|
257
|
+
|
|
258
|
+
assert len(transport.events) == 7
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_logging_after_close_is_ignored_and_counted(
|
|
262
|
+
make_monitor: Any, transport: FakeTransport
|
|
263
|
+
) -> None:
|
|
264
|
+
monitor = make_monitor()
|
|
265
|
+
monitor.close(timeout=2)
|
|
266
|
+
|
|
267
|
+
monitor.log(features={}, output=1, latency_ms=1.0)
|
|
268
|
+
|
|
269
|
+
assert transport.events == []
|
|
270
|
+
assert monitor.stats.dropped == 1
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""HttpTransport against a throwaway HTTP server on localhost."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from mnm_sdk import TransportError
|
|
14
|
+
from mnm_sdk.transport import HttpTransport
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Recorder(HTTPServer):
|
|
18
|
+
status = 202
|
|
19
|
+
requests: list[dict[str, Any]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Handler(BaseHTTPRequestHandler):
|
|
23
|
+
server: Recorder
|
|
24
|
+
|
|
25
|
+
def do_POST(self) -> None:
|
|
26
|
+
body = self.rfile.read(int(self.headers["Content-Length"]))
|
|
27
|
+
self.server.requests.append(
|
|
28
|
+
{
|
|
29
|
+
"path": self.path,
|
|
30
|
+
"content_type": self.headers["Content-Type"],
|
|
31
|
+
"body": json.loads(body),
|
|
32
|
+
"raw": body.decode("utf-8"),
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
self.send_response(self.server.status)
|
|
36
|
+
self.end_headers()
|
|
37
|
+
self.wfile.write(b"{}")
|
|
38
|
+
|
|
39
|
+
def log_message(self, *args: Any) -> None:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def server() -> Iterator[Recorder]:
|
|
45
|
+
recorder = Recorder(("127.0.0.1", 0), Handler)
|
|
46
|
+
recorder.requests = []
|
|
47
|
+
thread = threading.Thread(target=recorder.serve_forever, daemon=True)
|
|
48
|
+
thread.start()
|
|
49
|
+
yield recorder
|
|
50
|
+
recorder.shutdown()
|
|
51
|
+
recorder.server_close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def url(server: Recorder) -> str:
|
|
55
|
+
return f"http://127.0.0.1:{server.server_address[1]}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_send_posts_the_events_as_a_json_array_to_v1_ingest(server: Recorder) -> None:
|
|
59
|
+
events = [{"model": "churn", "features": {"age": 41}}]
|
|
60
|
+
|
|
61
|
+
HttpTransport(url(server) + "/").send(events)
|
|
62
|
+
|
|
63
|
+
(request,) = server.requests
|
|
64
|
+
assert request["path"] == "/v1/ingest"
|
|
65
|
+
assert request["content_type"] == "application/json"
|
|
66
|
+
assert request["body"] == events
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_values_json_cannot_encode_are_converted_not_fatal(server: Recorder) -> None:
|
|
70
|
+
class NumpyLikeScalar:
|
|
71
|
+
def item(self) -> float:
|
|
72
|
+
return 1.5
|
|
73
|
+
|
|
74
|
+
HttpTransport(url(server)).send(
|
|
75
|
+
[{"output": NumpyLikeScalar(), "features": {"x": {1, 2}.__class__}}]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
body = server.requests[0]["body"]
|
|
79
|
+
assert body[0]["output"] == 1.5
|
|
80
|
+
assert isinstance(body[0]["features"]["x"], str)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_a_server_error_is_retryable(server: Recorder) -> None:
|
|
84
|
+
server.status = 503
|
|
85
|
+
|
|
86
|
+
with pytest.raises(TransportError) as raised:
|
|
87
|
+
HttpTransport(url(server)).send([{}])
|
|
88
|
+
|
|
89
|
+
assert raised.value.retryable
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_a_validation_error_is_not_retryable(server: Recorder) -> None:
|
|
93
|
+
server.status = 422
|
|
94
|
+
|
|
95
|
+
with pytest.raises(TransportError) as raised:
|
|
96
|
+
HttpTransport(url(server)).send([{}])
|
|
97
|
+
|
|
98
|
+
assert not raised.value.retryable
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_an_unreachable_server_is_retryable() -> None:
|
|
102
|
+
with pytest.raises(TransportError) as raised:
|
|
103
|
+
HttpTransport("http://127.0.0.1:9", timeout=0.5).send([{}])
|
|
104
|
+
|
|
105
|
+
assert raised.value.retryable
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_nan_and_infinity_are_sent_as_null_because_json_has_neither(
|
|
109
|
+
server: Recorder,
|
|
110
|
+
) -> None:
|
|
111
|
+
class NumpyLikeNan:
|
|
112
|
+
def item(self) -> float:
|
|
113
|
+
return float("nan")
|
|
114
|
+
|
|
115
|
+
HttpTransport(url(server)).send(
|
|
116
|
+
[
|
|
117
|
+
{
|
|
118
|
+
"features": {"age": float("nan"), "x": float("inf"), "ok": 1.5},
|
|
119
|
+
"output": [NumpyLikeNan(), float("-inf")],
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
request = server.requests[0]
|
|
125
|
+
assert "NaN" not in request["raw"]
|
|
126
|
+
assert "Infinity" not in request["raw"]
|
|
127
|
+
assert request["body"] == [
|
|
128
|
+
{"features": {"age": None, "x": None, "ok": 1.5}, "output": [None, None]}
|
|
129
|
+
]
|