natsio-sysclient 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.
- natsio_sysclient-0.1.0/PKG-INFO +253 -0
- natsio_sysclient-0.1.0/README.md +232 -0
- natsio_sysclient-0.1.0/pyproject.toml +34 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/__init__.py +162 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/client.py +670 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/connz.py +93 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/entities.py +168 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/errors.py +92 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/healthz.py +73 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/jsz.py +118 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/options.py +185 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/py.typed +0 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/statsz.py +70 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/subsz.py +51 -0
- natsio_sysclient-0.1.0/src/natsio/sysclient/varz.py +223 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: natsio-sysclient
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: NATS system/monitoring API client for natsio (VARZ, CONNZ, JSZ, HEALTHZ, STATSZ, SUBSZ)
|
|
5
|
+
Keywords: nats,nats.io,monitoring,observability,system
|
|
6
|
+
Author: Corrupt Mane
|
|
7
|
+
Author-email: Corrupt Mane <corruptmane@gmail.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: natsio
|
|
16
|
+
Requires-Python: >=3.13
|
|
17
|
+
Project-URL: Homepage, https://github.com/corruptmane/natsio
|
|
18
|
+
Project-URL: Repository, https://github.com/corruptmane/natsio
|
|
19
|
+
Project-URL: Changelog, https://github.com/corruptmane/natsio/blob/main/extensions/natsio-sysclient/CHANGELOG.md
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# natsio-sysclient
|
|
23
|
+
|
|
24
|
+
Client for the NATS **system/monitoring API** — the `$SYS.REQ.SERVER` endpoints
|
|
25
|
+
a connection in the system account can ask about any server in the cluster.
|
|
26
|
+
A port of [orbit.go's `natssysclient`](https://github.com/synadia-io/orbit.go/tree/main/natssysclient),
|
|
27
|
+
with the same two request shapes, the same options, and the same paging model.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install natsio-sysclient
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Zero runtime dependencies beyond `natsio` itself.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import natsio
|
|
39
|
+
from natsio.sysclient import SysClient, ConnzOptions, JszOptions, SysClientOptions
|
|
40
|
+
|
|
41
|
+
nc = await natsio.connect("nats://localhost:4222", user="sys", password="pw")
|
|
42
|
+
sys = SysClient(nc)
|
|
43
|
+
|
|
44
|
+
# --- cluster ping: every server that answers ---------------------------------
|
|
45
|
+
for varz in await sys.varz_ping():
|
|
46
|
+
print(varz.server.id, varz.data.version, varz.data.connections)
|
|
47
|
+
|
|
48
|
+
# --- by server id: one named server ------------------------------------------
|
|
49
|
+
server_id = (await sys.varz_ping())[0].server.id
|
|
50
|
+
health = await sys.healthz(server_id)
|
|
51
|
+
assert health.data.status == "ok"
|
|
52
|
+
|
|
53
|
+
stats = await sys.statsz(server_id) # NOTE: payload lives under .statsz
|
|
54
|
+
print(stats.statsz.sent.msgs, stats.statsz.received.bytes)
|
|
55
|
+
|
|
56
|
+
# --- paged endpoints: an async iterator that walks offset -> total ------------
|
|
57
|
+
async for page in sys.all_connz(server_id, ConnzOptions(limit=64, auth=True)):
|
|
58
|
+
for conn in page.data.connections:
|
|
59
|
+
print(conn.cid, conn.account, conn.authorized_user, conn.rtt)
|
|
60
|
+
|
|
61
|
+
# --- one pager per server, consumable concurrently ---------------------------
|
|
62
|
+
for pager in await sys.all_jsz_ping(JszOptions(accounts=True, limit=10)):
|
|
63
|
+
async for page in pager:
|
|
64
|
+
for account in page.data.account_details or []:
|
|
65
|
+
print(page.server.id, account.name, account.storage)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Bounding a cluster ping — the knobs are independent and whichever fires first
|
|
69
|
+
ends the gather:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
sys = SysClient(nc, options=SysClientOptions(
|
|
73
|
+
timeout=5.0, # overall deadline; the FIRST reply always gets all of it
|
|
74
|
+
stall=0.3, # ...then stop after a 300 ms gap with no new reply
|
|
75
|
+
server_count=3, # ...or stop as soon as 3 servers have answered
|
|
76
|
+
))
|
|
77
|
+
await sys.varz_ping(server_count=5) # per-call override
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`stall` bounds only the gap *between* replies (nats.go `natsext.RequestMany`
|
|
81
|
+
parity), so the 300 ms default never penalises a slow cluster — it just stops
|
|
82
|
+
waiting once the cluster has gone quiet. A ping that collects nothing at all
|
|
83
|
+
therefore only ever ran out of `timeout`, or found no responders; both are
|
|
84
|
+
`NoResponsesError`, and the message says which.
|
|
85
|
+
|
|
86
|
+
Two things a ping deliberately does *not* do, matching the oracle's
|
|
87
|
+
`pingServers`:
|
|
88
|
+
|
|
89
|
+
- **one bad server fails the whole call.** If any responder answers with an
|
|
90
|
+
`error` envelope, `SysAPIError` is raised and the healthy servers' data is
|
|
91
|
+
discarded. Use the by-id shape per server if you need partial results.
|
|
92
|
+
- **a short gather is silent.** `server_count` is an upper bound, not a
|
|
93
|
+
contract: if only 3 of 5 servers answer before the stall, you get 3
|
|
94
|
+
responses and no signal. Compare `len(...)` against your expected size if
|
|
95
|
+
that matters.
|
|
96
|
+
|
|
97
|
+
## What's covered
|
|
98
|
+
|
|
99
|
+
| Endpoint | by id | cluster ping | paged iterator |
|
|
100
|
+
|---|---|---|---|
|
|
101
|
+
| `VARZ` | `varz()` | `varz_ping()` | — |
|
|
102
|
+
| `STATSZ` | `statsz()` | `statsz_ping()` | — |
|
|
103
|
+
| `HEALTHZ` | `healthz()` | `healthz_ping()` | — |
|
|
104
|
+
| `CONNZ` | `connz()` | `connz_ping()` | `all_connz()` / `all_connz_ping()` |
|
|
105
|
+
| `SUBSZ` | `subsz()` | `subsz_ping()` | `all_subsz()` / `all_subsz_ping()` |
|
|
106
|
+
| `JSZ` | `jsz()` | `jsz_ping()` | `all_jsz()` / `all_jsz_ping()` |
|
|
107
|
+
|
|
108
|
+
Each endpoint has its own options dataclass (`VarzOptions`, `ConnzOptions`,
|
|
109
|
+
`JszOptions`, `HealthzOptions`, `StatszOptions`, `SubszOptions`) and its own
|
|
110
|
+
typed response model.
|
|
111
|
+
|
|
112
|
+
**Responses are `JsonModel`s.** Fields a newer server adds that this package
|
|
113
|
+
does not name are kept in `extra` and re-emitted on `to_wire()`, so upgrading
|
|
114
|
+
the server never silently drops data:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
varz = await sys.varz(server_id)
|
|
118
|
+
varz.server.extra["feature_flags"] # 2.14 addition, not in the oracle's structs
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Paging** is driven by the endpoint's own `offset`/`total`. `all_connz` and
|
|
122
|
+
`all_subsz` walk until the page is short or `total` is reached; `all_jsz` pages
|
|
123
|
+
`account_details` against the `accounts` total and short-circuits to a single
|
|
124
|
+
page unless `JszOptions(accounts=True)` is set — the same rule the oracle's
|
|
125
|
+
`AllJsz` applies — and also to a single page when `JszOptions(account=...)`
|
|
126
|
+
names one account, because nats-server ignores `offset` for that filter while
|
|
127
|
+
still reporting the *unfiltered* `accounts` count (see Wire contract below).
|
|
128
|
+
A negative starting `offset` is clamped to 0, the way the server clamps it.
|
|
129
|
+
|
|
130
|
+
> **Paging is only complete when the page order is stable across requests.**
|
|
131
|
+
> `offset`-based walking (which the oracle's `AllConnz`/`AllServerSubsz` do
|
|
132
|
+
> identically) assumes each request returns entries in the same order. That
|
|
133
|
+
> holds for the default `CONNZ` sort (by `cid`, a total order), and the live
|
|
134
|
+
> suite exercises exactly that. It does **not** hold for `SUBSZ` (whose order is
|
|
135
|
+
> unspecified) or for a `CONNZ` sort whose key has ties: entries can be
|
|
136
|
+
> duplicated or skipped between pages, with no error. Treat `all_subsz` and
|
|
137
|
+
> tie-prone `CONNZ` sorts as best-effort; for a guaranteed-complete walk use the
|
|
138
|
+
> default `CONNZ` ordering, or a single large page.
|
|
139
|
+
|
|
140
|
+
Pagers are lazy (nothing is requested until you iterate), support `await` as a
|
|
141
|
+
no-op, and can be closed early with `aclose()` or `async with`. They are
|
|
142
|
+
**single-pass and single-consumer**: re-iterating a spent pager, advancing one
|
|
143
|
+
from two tasks at once, or closing one mid-advance raises `PagerStateError`
|
|
144
|
+
rather than quietly yielding nothing.
|
|
145
|
+
|
|
146
|
+
## Errors
|
|
147
|
+
|
|
148
|
+
Everything derives from `SysClientError` (itself a `natsio.errors.NATSError`).
|
|
149
|
+
Nothing fails quietly:
|
|
150
|
+
|
|
151
|
+
| Error | When |
|
|
152
|
+
|---|---|
|
|
153
|
+
| `SysValidationError` | empty/dotted/wildcard server id, `"PING"` used as a server id, non-positive timeout (client-wide **or** per-call), stall or server count — raised before any I/O |
|
|
154
|
+
| `InvalidServerIDError` | no responders on `$SYS.REQ.SERVER.<id>.<ENDPOINT>` — unknown id, or the connection is not in `$SYS` |
|
|
155
|
+
| `NoResponsesError` | a cluster ping collected **zero** responses (an empty list would be indistinguishable from a zero-server cluster); the message names the bound that fired and the elapsed time |
|
|
156
|
+
| `SysAPIError` | the server answered with an `error` envelope; carries `code`, `err_code`, `api_description` |
|
|
157
|
+
| `InvalidResponseError` | the response payload was not a JSON object, or its `error` block was malformed (non-object, non-integer `code`/`err_code`) |
|
|
158
|
+
| `PagerStateError` | a `PagedResponses` was re-iterated, shared between tasks, or closed mid-advance |
|
|
159
|
+
|
|
160
|
+
`natsio.errors.TimeoutError` and `ConnectionClosedError` pass through
|
|
161
|
+
unwrapped — re-typing them would hide which one happened.
|
|
162
|
+
|
|
163
|
+
A *failed health check* is deliberately not an error: `healthz()` returns
|
|
164
|
+
`status="error"` with `status_code` and `errors` as data, because the request
|
|
165
|
+
itself succeeded.
|
|
166
|
+
|
|
167
|
+
## Wire contract
|
|
168
|
+
|
|
169
|
+
Subjects, JSON field names and option keys are pinned to orbit.go
|
|
170
|
+
`natssysclient` (`api.go`, `varz.go`, `connz.go`, `jsz_server.go`, `healthz.go`,
|
|
171
|
+
`statsz_server.go`, `subsz_server.go`) and re-verified against nats-server
|
|
172
|
+
2.14.3.
|
|
173
|
+
|
|
174
|
+
- `$SYS.REQ.SERVER.<id>.<ENDPOINT>` and `$SYS.REQ.SERVER.PING.<ENDPOINT>`, with
|
|
175
|
+
`ENDPOINT` one of `VARZ`, `CONNZ`, `JSZ`, `HEALTHZ`, `STATSZ`, `SUBSZ`.
|
|
176
|
+
- Envelopes are `{"server": …, "data": …}` — except `STATSZ`, whose payload key
|
|
177
|
+
is `statsz`, and any response may carry `error` instead.
|
|
178
|
+
- Go `time.Duration` fields arrive as integer nanoseconds and decode to
|
|
179
|
+
`timedelta`; `time.Time` fields decode to aware `datetime`. `uptime`, `rtt`
|
|
180
|
+
and `idle` are the server's own pre-formatted **strings** and stay strings.
|
|
181
|
+
- Go's embedded `JetStreamStats` (in `JSZ`) and `SublistStats` (in `SUBSZ`)
|
|
182
|
+
are inlined at the top level on the wire, so they are spelled out flat here.
|
|
183
|
+
|
|
184
|
+
Deliberate departures, none of which changes what a *correct* request looks
|
|
185
|
+
like on the wire:
|
|
186
|
+
|
|
187
|
+
1. **Unset options are omitted.** Several of the oracle's structs lack
|
|
188
|
+
`omitempty` and therefore emit their zero values (`{"sort":"","auth":false,
|
|
189
|
+
"offset":0,…}`). A missing key decodes to exactly that zero server-side, so
|
|
190
|
+
the payloads are equivalent.
|
|
191
|
+
2. **`JszOptions(account=...)` is not paged.** nats-server ignores `offset`
|
|
192
|
+
when `account` is set, but still reports `accounts` as the count of *all*
|
|
193
|
+
JS-enabled accounts — the very number the oracle's `AllJsz` walks to. On a
|
|
194
|
+
server with N JS accounts, the oracle's loop therefore issues N `JSZ`
|
|
195
|
+
requests and yields the same single account N times. `all_jsz` /
|
|
196
|
+
`all_jsz_ping` yield one page instead. (Bug inherited from the oracle,
|
|
197
|
+
fixed here; verified against 2.14.3 with five JS accounts.)
|
|
198
|
+
3. **`"PING"` is rejected as a server id.** The oracle interpolates it into the
|
|
199
|
+
by-id subject like any other string, which silently turns `varz("PING")`
|
|
200
|
+
into a cluster ping that keeps only the first reply. Here it is a
|
|
201
|
+
`SysValidationError`; use `varz_ping()`.
|
|
202
|
+
4. **`HealthzError.type` is a `str`, not an enum.** The oracle declares
|
|
203
|
+
`HealthZErrorType` as an `int`; nats-server 2.14.3 marshals it as an
|
|
204
|
+
upper-case string (`"BAD_REQUEST"`, `"ACCOUNT"`, `"STREAM"`, `"CONSUMER"` —
|
|
205
|
+
all four verified live), which the Go struct cannot decode at all. The
|
|
206
|
+
`HealthzErrorType` `StrEnum` lists the verified values for comparison, but
|
|
207
|
+
the field stays `str` so an unknown category from a newer server is data,
|
|
208
|
+
not a decode failure.
|
|
209
|
+
|
|
210
|
+
## Scope limits
|
|
211
|
+
|
|
212
|
+
- **Server endpoints only.** The account-scoped `$SYS.REQ.ACCOUNT.*` family
|
|
213
|
+
(`STATZ`, `CONNZ`, `SUBSZ`, `JSZ`, `INFO`) and `$SYS.REQ.USER.INFO` are out of
|
|
214
|
+
scope, matching the oracle. So are `ROUTEZ`, `GATEWAYZ`, `LEAFZ` and
|
|
215
|
+
`ACCOUNTZ`, which the oracle also does not model.
|
|
216
|
+
- **No cluster-shaped tests.** The suite runs a single server, so the ping path
|
|
217
|
+
is proven to return exactly one response whose id matches the by-id shape;
|
|
218
|
+
multi-server gather (`server_count` > 1, stall short-circuiting across
|
|
219
|
+
servers) is exercised only in its degenerate one-server form. The slow-reply
|
|
220
|
+
and mute-reply paths are covered with a stand-in responder in a regular
|
|
221
|
+
account, where `$SYS.REQ.SERVER.PING.VARZ` is an ordinary subject.
|
|
222
|
+
- **A connection closed mid-ping can still surface as `NoResponsesError`.**
|
|
223
|
+
`Client.close()` closes its request sinks *before* it advances the connection
|
|
224
|
+
state, so at the instant the gather ends the connection can still read
|
|
225
|
+
`CONNECTED` and the closure is indistinguishable from "nobody answered". The
|
|
226
|
+
wake path itself is correct — the ping unparks in milliseconds, it never
|
|
227
|
+
hangs to the deadline — and an already-closed or reconnecting connection does
|
|
228
|
+
raise `ConnectionClosedError`. Removing the residual window needs
|
|
229
|
+
`Client.request_many` to end its stream distinguishably on closure.
|
|
230
|
+
- **Operator JWT claims are raw JSON.** `Varz.trusted_operators_claim` is a
|
|
231
|
+
`list[dict]`; decoding JWTs would mean a dependency, and this package has
|
|
232
|
+
none.
|
|
233
|
+
- **`HealthzErrorType.CONNECTION` / `.JETSTREAM` are absent.** The oracle names
|
|
234
|
+
two more categories whose string spelling could not be provoked on 2.14.3;
|
|
235
|
+
guessing them would be an unverified wire claim. They still decode fine — as
|
|
236
|
+
their raw string.
|
|
237
|
+
|
|
238
|
+
## Tests
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
uv run pytest extensions/natsio-sysclient -q
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Live tests need `tools/.bin/nats-server` (or `NATS_SERVER_BIN`) and start it
|
|
245
|
+
with a `$SYS` account plus a JetStream-enabled `APP` account.
|
|
246
|
+
|
|
247
|
+
## Example
|
|
248
|
+
|
|
249
|
+
A runnable script is at [`examples/basic.py`](https://github.com/corruptmane/natsio/blob/main/extensions/natsio-sysclient/examples/basic.py) — start a server with `just server`, then:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
python extensions/natsio-sysclient/examples/basic.py
|
|
253
|
+
```
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# natsio-sysclient
|
|
2
|
+
|
|
3
|
+
Client for the NATS **system/monitoring API** — the `$SYS.REQ.SERVER` endpoints
|
|
4
|
+
a connection in the system account can ask about any server in the cluster.
|
|
5
|
+
A port of [orbit.go's `natssysclient`](https://github.com/synadia-io/orbit.go/tree/main/natssysclient),
|
|
6
|
+
with the same two request shapes, the same options, and the same paging model.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install natsio-sysclient
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Zero runtime dependencies beyond `natsio` itself.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import natsio
|
|
18
|
+
from natsio.sysclient import SysClient, ConnzOptions, JszOptions, SysClientOptions
|
|
19
|
+
|
|
20
|
+
nc = await natsio.connect("nats://localhost:4222", user="sys", password="pw")
|
|
21
|
+
sys = SysClient(nc)
|
|
22
|
+
|
|
23
|
+
# --- cluster ping: every server that answers ---------------------------------
|
|
24
|
+
for varz in await sys.varz_ping():
|
|
25
|
+
print(varz.server.id, varz.data.version, varz.data.connections)
|
|
26
|
+
|
|
27
|
+
# --- by server id: one named server ------------------------------------------
|
|
28
|
+
server_id = (await sys.varz_ping())[0].server.id
|
|
29
|
+
health = await sys.healthz(server_id)
|
|
30
|
+
assert health.data.status == "ok"
|
|
31
|
+
|
|
32
|
+
stats = await sys.statsz(server_id) # NOTE: payload lives under .statsz
|
|
33
|
+
print(stats.statsz.sent.msgs, stats.statsz.received.bytes)
|
|
34
|
+
|
|
35
|
+
# --- paged endpoints: an async iterator that walks offset -> total ------------
|
|
36
|
+
async for page in sys.all_connz(server_id, ConnzOptions(limit=64, auth=True)):
|
|
37
|
+
for conn in page.data.connections:
|
|
38
|
+
print(conn.cid, conn.account, conn.authorized_user, conn.rtt)
|
|
39
|
+
|
|
40
|
+
# --- one pager per server, consumable concurrently ---------------------------
|
|
41
|
+
for pager in await sys.all_jsz_ping(JszOptions(accounts=True, limit=10)):
|
|
42
|
+
async for page in pager:
|
|
43
|
+
for account in page.data.account_details or []:
|
|
44
|
+
print(page.server.id, account.name, account.storage)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Bounding a cluster ping — the knobs are independent and whichever fires first
|
|
48
|
+
ends the gather:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
sys = SysClient(nc, options=SysClientOptions(
|
|
52
|
+
timeout=5.0, # overall deadline; the FIRST reply always gets all of it
|
|
53
|
+
stall=0.3, # ...then stop after a 300 ms gap with no new reply
|
|
54
|
+
server_count=3, # ...or stop as soon as 3 servers have answered
|
|
55
|
+
))
|
|
56
|
+
await sys.varz_ping(server_count=5) # per-call override
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`stall` bounds only the gap *between* replies (nats.go `natsext.RequestMany`
|
|
60
|
+
parity), so the 300 ms default never penalises a slow cluster — it just stops
|
|
61
|
+
waiting once the cluster has gone quiet. A ping that collects nothing at all
|
|
62
|
+
therefore only ever ran out of `timeout`, or found no responders; both are
|
|
63
|
+
`NoResponsesError`, and the message says which.
|
|
64
|
+
|
|
65
|
+
Two things a ping deliberately does *not* do, matching the oracle's
|
|
66
|
+
`pingServers`:
|
|
67
|
+
|
|
68
|
+
- **one bad server fails the whole call.** If any responder answers with an
|
|
69
|
+
`error` envelope, `SysAPIError` is raised and the healthy servers' data is
|
|
70
|
+
discarded. Use the by-id shape per server if you need partial results.
|
|
71
|
+
- **a short gather is silent.** `server_count` is an upper bound, not a
|
|
72
|
+
contract: if only 3 of 5 servers answer before the stall, you get 3
|
|
73
|
+
responses and no signal. Compare `len(...)` against your expected size if
|
|
74
|
+
that matters.
|
|
75
|
+
|
|
76
|
+
## What's covered
|
|
77
|
+
|
|
78
|
+
| Endpoint | by id | cluster ping | paged iterator |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| `VARZ` | `varz()` | `varz_ping()` | — |
|
|
81
|
+
| `STATSZ` | `statsz()` | `statsz_ping()` | — |
|
|
82
|
+
| `HEALTHZ` | `healthz()` | `healthz_ping()` | — |
|
|
83
|
+
| `CONNZ` | `connz()` | `connz_ping()` | `all_connz()` / `all_connz_ping()` |
|
|
84
|
+
| `SUBSZ` | `subsz()` | `subsz_ping()` | `all_subsz()` / `all_subsz_ping()` |
|
|
85
|
+
| `JSZ` | `jsz()` | `jsz_ping()` | `all_jsz()` / `all_jsz_ping()` |
|
|
86
|
+
|
|
87
|
+
Each endpoint has its own options dataclass (`VarzOptions`, `ConnzOptions`,
|
|
88
|
+
`JszOptions`, `HealthzOptions`, `StatszOptions`, `SubszOptions`) and its own
|
|
89
|
+
typed response model.
|
|
90
|
+
|
|
91
|
+
**Responses are `JsonModel`s.** Fields a newer server adds that this package
|
|
92
|
+
does not name are kept in `extra` and re-emitted on `to_wire()`, so upgrading
|
|
93
|
+
the server never silently drops data:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
varz = await sys.varz(server_id)
|
|
97
|
+
varz.server.extra["feature_flags"] # 2.14 addition, not in the oracle's structs
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Paging** is driven by the endpoint's own `offset`/`total`. `all_connz` and
|
|
101
|
+
`all_subsz` walk until the page is short or `total` is reached; `all_jsz` pages
|
|
102
|
+
`account_details` against the `accounts` total and short-circuits to a single
|
|
103
|
+
page unless `JszOptions(accounts=True)` is set — the same rule the oracle's
|
|
104
|
+
`AllJsz` applies — and also to a single page when `JszOptions(account=...)`
|
|
105
|
+
names one account, because nats-server ignores `offset` for that filter while
|
|
106
|
+
still reporting the *unfiltered* `accounts` count (see Wire contract below).
|
|
107
|
+
A negative starting `offset` is clamped to 0, the way the server clamps it.
|
|
108
|
+
|
|
109
|
+
> **Paging is only complete when the page order is stable across requests.**
|
|
110
|
+
> `offset`-based walking (which the oracle's `AllConnz`/`AllServerSubsz` do
|
|
111
|
+
> identically) assumes each request returns entries in the same order. That
|
|
112
|
+
> holds for the default `CONNZ` sort (by `cid`, a total order), and the live
|
|
113
|
+
> suite exercises exactly that. It does **not** hold for `SUBSZ` (whose order is
|
|
114
|
+
> unspecified) or for a `CONNZ` sort whose key has ties: entries can be
|
|
115
|
+
> duplicated or skipped between pages, with no error. Treat `all_subsz` and
|
|
116
|
+
> tie-prone `CONNZ` sorts as best-effort; for a guaranteed-complete walk use the
|
|
117
|
+
> default `CONNZ` ordering, or a single large page.
|
|
118
|
+
|
|
119
|
+
Pagers are lazy (nothing is requested until you iterate), support `await` as a
|
|
120
|
+
no-op, and can be closed early with `aclose()` or `async with`. They are
|
|
121
|
+
**single-pass and single-consumer**: re-iterating a spent pager, advancing one
|
|
122
|
+
from two tasks at once, or closing one mid-advance raises `PagerStateError`
|
|
123
|
+
rather than quietly yielding nothing.
|
|
124
|
+
|
|
125
|
+
## Errors
|
|
126
|
+
|
|
127
|
+
Everything derives from `SysClientError` (itself a `natsio.errors.NATSError`).
|
|
128
|
+
Nothing fails quietly:
|
|
129
|
+
|
|
130
|
+
| Error | When |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `SysValidationError` | empty/dotted/wildcard server id, `"PING"` used as a server id, non-positive timeout (client-wide **or** per-call), stall or server count — raised before any I/O |
|
|
133
|
+
| `InvalidServerIDError` | no responders on `$SYS.REQ.SERVER.<id>.<ENDPOINT>` — unknown id, or the connection is not in `$SYS` |
|
|
134
|
+
| `NoResponsesError` | a cluster ping collected **zero** responses (an empty list would be indistinguishable from a zero-server cluster); the message names the bound that fired and the elapsed time |
|
|
135
|
+
| `SysAPIError` | the server answered with an `error` envelope; carries `code`, `err_code`, `api_description` |
|
|
136
|
+
| `InvalidResponseError` | the response payload was not a JSON object, or its `error` block was malformed (non-object, non-integer `code`/`err_code`) |
|
|
137
|
+
| `PagerStateError` | a `PagedResponses` was re-iterated, shared between tasks, or closed mid-advance |
|
|
138
|
+
|
|
139
|
+
`natsio.errors.TimeoutError` and `ConnectionClosedError` pass through
|
|
140
|
+
unwrapped — re-typing them would hide which one happened.
|
|
141
|
+
|
|
142
|
+
A *failed health check* is deliberately not an error: `healthz()` returns
|
|
143
|
+
`status="error"` with `status_code` and `errors` as data, because the request
|
|
144
|
+
itself succeeded.
|
|
145
|
+
|
|
146
|
+
## Wire contract
|
|
147
|
+
|
|
148
|
+
Subjects, JSON field names and option keys are pinned to orbit.go
|
|
149
|
+
`natssysclient` (`api.go`, `varz.go`, `connz.go`, `jsz_server.go`, `healthz.go`,
|
|
150
|
+
`statsz_server.go`, `subsz_server.go`) and re-verified against nats-server
|
|
151
|
+
2.14.3.
|
|
152
|
+
|
|
153
|
+
- `$SYS.REQ.SERVER.<id>.<ENDPOINT>` and `$SYS.REQ.SERVER.PING.<ENDPOINT>`, with
|
|
154
|
+
`ENDPOINT` one of `VARZ`, `CONNZ`, `JSZ`, `HEALTHZ`, `STATSZ`, `SUBSZ`.
|
|
155
|
+
- Envelopes are `{"server": …, "data": …}` — except `STATSZ`, whose payload key
|
|
156
|
+
is `statsz`, and any response may carry `error` instead.
|
|
157
|
+
- Go `time.Duration` fields arrive as integer nanoseconds and decode to
|
|
158
|
+
`timedelta`; `time.Time` fields decode to aware `datetime`. `uptime`, `rtt`
|
|
159
|
+
and `idle` are the server's own pre-formatted **strings** and stay strings.
|
|
160
|
+
- Go's embedded `JetStreamStats` (in `JSZ`) and `SublistStats` (in `SUBSZ`)
|
|
161
|
+
are inlined at the top level on the wire, so they are spelled out flat here.
|
|
162
|
+
|
|
163
|
+
Deliberate departures, none of which changes what a *correct* request looks
|
|
164
|
+
like on the wire:
|
|
165
|
+
|
|
166
|
+
1. **Unset options are omitted.** Several of the oracle's structs lack
|
|
167
|
+
`omitempty` and therefore emit their zero values (`{"sort":"","auth":false,
|
|
168
|
+
"offset":0,…}`). A missing key decodes to exactly that zero server-side, so
|
|
169
|
+
the payloads are equivalent.
|
|
170
|
+
2. **`JszOptions(account=...)` is not paged.** nats-server ignores `offset`
|
|
171
|
+
when `account` is set, but still reports `accounts` as the count of *all*
|
|
172
|
+
JS-enabled accounts — the very number the oracle's `AllJsz` walks to. On a
|
|
173
|
+
server with N JS accounts, the oracle's loop therefore issues N `JSZ`
|
|
174
|
+
requests and yields the same single account N times. `all_jsz` /
|
|
175
|
+
`all_jsz_ping` yield one page instead. (Bug inherited from the oracle,
|
|
176
|
+
fixed here; verified against 2.14.3 with five JS accounts.)
|
|
177
|
+
3. **`"PING"` is rejected as a server id.** The oracle interpolates it into the
|
|
178
|
+
by-id subject like any other string, which silently turns `varz("PING")`
|
|
179
|
+
into a cluster ping that keeps only the first reply. Here it is a
|
|
180
|
+
`SysValidationError`; use `varz_ping()`.
|
|
181
|
+
4. **`HealthzError.type` is a `str`, not an enum.** The oracle declares
|
|
182
|
+
`HealthZErrorType` as an `int`; nats-server 2.14.3 marshals it as an
|
|
183
|
+
upper-case string (`"BAD_REQUEST"`, `"ACCOUNT"`, `"STREAM"`, `"CONSUMER"` —
|
|
184
|
+
all four verified live), which the Go struct cannot decode at all. The
|
|
185
|
+
`HealthzErrorType` `StrEnum` lists the verified values for comparison, but
|
|
186
|
+
the field stays `str` so an unknown category from a newer server is data,
|
|
187
|
+
not a decode failure.
|
|
188
|
+
|
|
189
|
+
## Scope limits
|
|
190
|
+
|
|
191
|
+
- **Server endpoints only.** The account-scoped `$SYS.REQ.ACCOUNT.*` family
|
|
192
|
+
(`STATZ`, `CONNZ`, `SUBSZ`, `JSZ`, `INFO`) and `$SYS.REQ.USER.INFO` are out of
|
|
193
|
+
scope, matching the oracle. So are `ROUTEZ`, `GATEWAYZ`, `LEAFZ` and
|
|
194
|
+
`ACCOUNTZ`, which the oracle also does not model.
|
|
195
|
+
- **No cluster-shaped tests.** The suite runs a single server, so the ping path
|
|
196
|
+
is proven to return exactly one response whose id matches the by-id shape;
|
|
197
|
+
multi-server gather (`server_count` > 1, stall short-circuiting across
|
|
198
|
+
servers) is exercised only in its degenerate one-server form. The slow-reply
|
|
199
|
+
and mute-reply paths are covered with a stand-in responder in a regular
|
|
200
|
+
account, where `$SYS.REQ.SERVER.PING.VARZ` is an ordinary subject.
|
|
201
|
+
- **A connection closed mid-ping can still surface as `NoResponsesError`.**
|
|
202
|
+
`Client.close()` closes its request sinks *before* it advances the connection
|
|
203
|
+
state, so at the instant the gather ends the connection can still read
|
|
204
|
+
`CONNECTED` and the closure is indistinguishable from "nobody answered". The
|
|
205
|
+
wake path itself is correct — the ping unparks in milliseconds, it never
|
|
206
|
+
hangs to the deadline — and an already-closed or reconnecting connection does
|
|
207
|
+
raise `ConnectionClosedError`. Removing the residual window needs
|
|
208
|
+
`Client.request_many` to end its stream distinguishably on closure.
|
|
209
|
+
- **Operator JWT claims are raw JSON.** `Varz.trusted_operators_claim` is a
|
|
210
|
+
`list[dict]`; decoding JWTs would mean a dependency, and this package has
|
|
211
|
+
none.
|
|
212
|
+
- **`HealthzErrorType.CONNECTION` / `.JETSTREAM` are absent.** The oracle names
|
|
213
|
+
two more categories whose string spelling could not be provoked on 2.14.3;
|
|
214
|
+
guessing them would be an unverified wire claim. They still decode fine — as
|
|
215
|
+
their raw string.
|
|
216
|
+
|
|
217
|
+
## Tests
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
uv run pytest extensions/natsio-sysclient -q
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Live tests need `tools/.bin/nats-server` (or `NATS_SERVER_BIN`) and start it
|
|
224
|
+
with a `$SYS` account plus a JetStream-enabled `APP` account.
|
|
225
|
+
|
|
226
|
+
## Example
|
|
227
|
+
|
|
228
|
+
A runnable script is at [`examples/basic.py`](https://github.com/corruptmane/natsio/blob/main/extensions/natsio-sysclient/examples/basic.py) — start a server with `just server`, then:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
python extensions/natsio-sysclient/examples/basic.py
|
|
232
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.10,<0.11"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "natsio-sysclient"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "NATS system/monitoring API client for natsio (VARZ, CONNZ, JSZ, HEALTHZ, STATSZ, SUBSZ)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
authors = [{ name = "Corrupt Mane", email = "corruptmane@gmail.com" }]
|
|
13
|
+
keywords = ["nats", "nats.io", "monitoring", "observability", "system"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["natsio"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/corruptmane/natsio"
|
|
26
|
+
Repository = "https://github.com/corruptmane/natsio"
|
|
27
|
+
Changelog = "https://github.com/corruptmane/natsio/blob/main/extensions/natsio-sysclient/CHANGELOG.md"
|
|
28
|
+
|
|
29
|
+
[tool.uv.sources]
|
|
30
|
+
natsio = { workspace = true }
|
|
31
|
+
|
|
32
|
+
[tool.uv.build-backend]
|
|
33
|
+
module-name = "natsio.sysclient"
|
|
34
|
+
namespace = true
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""NATS system/monitoring API client — the `$SYS.REQ.SERVER` endpoints.
|
|
2
|
+
|
|
3
|
+
Port of orbit.go's `natssysclient`. Connect with system-account credentials,
|
|
4
|
+
wrap the client, and ask one server or the whole cluster:
|
|
5
|
+
|
|
6
|
+
import natsio
|
|
7
|
+
from natsio.sysclient import SysClient, ConnzOptions
|
|
8
|
+
|
|
9
|
+
nc = await natsio.connect("nats://localhost:4222", user="sys", password="pw")
|
|
10
|
+
sys = SysClient(nc)
|
|
11
|
+
|
|
12
|
+
# every server that answers within the stall/timeout window
|
|
13
|
+
for varz in await sys.varz_ping():
|
|
14
|
+
print(varz.server.id, varz.data.version, varz.data.connections)
|
|
15
|
+
|
|
16
|
+
# one named server
|
|
17
|
+
health = await sys.healthz(varz.server.id)
|
|
18
|
+
assert health.data.status == "ok"
|
|
19
|
+
|
|
20
|
+
# offset/limit paging, walked for you
|
|
21
|
+
async for page in sys.all_connz(varz.server.id, ConnzOptions(limit=64, auth=True)):
|
|
22
|
+
for conn in page.data.connections:
|
|
23
|
+
print(conn.cid, conn.authorized_user, conn.account)
|
|
24
|
+
|
|
25
|
+
Endpoints: `VARZ`, `CONNZ`, `JSZ`, `HEALTHZ`, `STATSZ`, `SUBSZ` — each with a
|
|
26
|
+
by-id method (`varz`), a cluster-ping method (`varz_ping`), and, where the
|
|
27
|
+
endpoint pages, an async iterator (`all_connz`, `all_subsz`, `all_jsz`).
|
|
28
|
+
|
|
29
|
+
Responses are `JsonModel`s: fields a newer server adds are preserved in `extra`
|
|
30
|
+
instead of being dropped.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from natsio.sysclient.client import (
|
|
34
|
+
DEFAULT_REQUEST_TIMEOUT,
|
|
35
|
+
DEFAULT_STALL,
|
|
36
|
+
PING_TARGET,
|
|
37
|
+
SYS_REQ_SERVER_PREFIX,
|
|
38
|
+
Endpoint,
|
|
39
|
+
PagedResponses,
|
|
40
|
+
SysClient,
|
|
41
|
+
SysClientOptions,
|
|
42
|
+
)
|
|
43
|
+
from natsio.sysclient.connz import ConnInfo, Connz, ConnzResponse, TLSPeerCert
|
|
44
|
+
from natsio.sysclient.entities import (
|
|
45
|
+
APIError,
|
|
46
|
+
DataStats,
|
|
47
|
+
JetStreamAPIStats,
|
|
48
|
+
JetStreamConfig,
|
|
49
|
+
JetStreamStats,
|
|
50
|
+
JetStreamVarz,
|
|
51
|
+
MetaClusterInfo,
|
|
52
|
+
PeerInfo,
|
|
53
|
+
ServerInfo,
|
|
54
|
+
SlowConsumersStats,
|
|
55
|
+
SubDetail,
|
|
56
|
+
)
|
|
57
|
+
from natsio.sysclient.errors import (
|
|
58
|
+
InvalidResponseError,
|
|
59
|
+
InvalidServerIDError,
|
|
60
|
+
NoResponsesError,
|
|
61
|
+
PagerStateError,
|
|
62
|
+
SysAPIError,
|
|
63
|
+
SysClientError,
|
|
64
|
+
SysValidationError,
|
|
65
|
+
)
|
|
66
|
+
from natsio.sysclient.healthz import Healthz, HealthzError, HealthzErrorType, HealthzResponse
|
|
67
|
+
from natsio.sysclient.jsz import AccountDetail, JSInfo, JszResponse, RaftGroupDetail, StreamDetail, StreamSourceInfo
|
|
68
|
+
from natsio.sysclient.options import (
|
|
69
|
+
ConnState,
|
|
70
|
+
ConnzOptions,
|
|
71
|
+
EventFilterOptions,
|
|
72
|
+
HealthzOptions,
|
|
73
|
+
JszOptions,
|
|
74
|
+
SortOpt,
|
|
75
|
+
StatszOptions,
|
|
76
|
+
SubszOptions,
|
|
77
|
+
VarzOptions,
|
|
78
|
+
)
|
|
79
|
+
from natsio.sysclient.statsz import GatewayStat, RouteStat, ServerStats, StatszResponse
|
|
80
|
+
from natsio.sysclient.subsz import Subsz, SubszResponse
|
|
81
|
+
from natsio.sysclient.varz import (
|
|
82
|
+
ClusterOptsVarz,
|
|
83
|
+
DenyRules,
|
|
84
|
+
GatewayOptsVarz,
|
|
85
|
+
LeafNodeOptsVarz,
|
|
86
|
+
MQTTOptsVarz,
|
|
87
|
+
OCSPResponseCacheVarz,
|
|
88
|
+
RemoteGatewayOptsVarz,
|
|
89
|
+
RemoteLeafOptsVarz,
|
|
90
|
+
Varz,
|
|
91
|
+
VarzResponse,
|
|
92
|
+
WebsocketOptsVarz,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
"DEFAULT_REQUEST_TIMEOUT",
|
|
97
|
+
"DEFAULT_STALL",
|
|
98
|
+
"PING_TARGET",
|
|
99
|
+
"SYS_REQ_SERVER_PREFIX",
|
|
100
|
+
"APIError",
|
|
101
|
+
"AccountDetail",
|
|
102
|
+
"ClusterOptsVarz",
|
|
103
|
+
"ConnInfo",
|
|
104
|
+
"ConnState",
|
|
105
|
+
"Connz",
|
|
106
|
+
"ConnzOptions",
|
|
107
|
+
"ConnzResponse",
|
|
108
|
+
"DataStats",
|
|
109
|
+
"DenyRules",
|
|
110
|
+
"Endpoint",
|
|
111
|
+
"EventFilterOptions",
|
|
112
|
+
"GatewayOptsVarz",
|
|
113
|
+
"GatewayStat",
|
|
114
|
+
"Healthz",
|
|
115
|
+
"HealthzError",
|
|
116
|
+
"HealthzErrorType",
|
|
117
|
+
"HealthzOptions",
|
|
118
|
+
"HealthzResponse",
|
|
119
|
+
"InvalidResponseError",
|
|
120
|
+
"InvalidServerIDError",
|
|
121
|
+
"JSInfo",
|
|
122
|
+
"JetStreamAPIStats",
|
|
123
|
+
"JetStreamConfig",
|
|
124
|
+
"JetStreamStats",
|
|
125
|
+
"JetStreamVarz",
|
|
126
|
+
"JszOptions",
|
|
127
|
+
"JszResponse",
|
|
128
|
+
"LeafNodeOptsVarz",
|
|
129
|
+
"MQTTOptsVarz",
|
|
130
|
+
"MetaClusterInfo",
|
|
131
|
+
"NoResponsesError",
|
|
132
|
+
"OCSPResponseCacheVarz",
|
|
133
|
+
"PagedResponses",
|
|
134
|
+
"PagerStateError",
|
|
135
|
+
"PeerInfo",
|
|
136
|
+
"RaftGroupDetail",
|
|
137
|
+
"RemoteGatewayOptsVarz",
|
|
138
|
+
"RemoteLeafOptsVarz",
|
|
139
|
+
"RouteStat",
|
|
140
|
+
"ServerInfo",
|
|
141
|
+
"ServerStats",
|
|
142
|
+
"SlowConsumersStats",
|
|
143
|
+
"SortOpt",
|
|
144
|
+
"StatszOptions",
|
|
145
|
+
"StatszResponse",
|
|
146
|
+
"StreamDetail",
|
|
147
|
+
"StreamSourceInfo",
|
|
148
|
+
"SubDetail",
|
|
149
|
+
"Subsz",
|
|
150
|
+
"SubszOptions",
|
|
151
|
+
"SubszResponse",
|
|
152
|
+
"SysAPIError",
|
|
153
|
+
"SysClient",
|
|
154
|
+
"SysClientError",
|
|
155
|
+
"SysClientOptions",
|
|
156
|
+
"SysValidationError",
|
|
157
|
+
"TLSPeerCert",
|
|
158
|
+
"Varz",
|
|
159
|
+
"VarzOptions",
|
|
160
|
+
"VarzResponse",
|
|
161
|
+
"WebsocketOptsVarz",
|
|
162
|
+
]
|