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.
@@ -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
+ ]