This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch docs
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/docs by this push:
     new 4097bce106 docs: /admin/ratelimit + RateLimitGuard topic-page updates 
for snapshot SPI + 9.5.0 release-notes entry (TODO-89)
4097bce106 is described below

commit 4097bce10667ebf75820cffbd6c7b91f049ef591
Author: James Bognar <[email protected]>
AuthorDate: Tue May 26 13:53:19 2026 -0400

    docs: /admin/ratelimit + RateLimitGuard topic-page updates for snapshot SPI 
+ 9.5.0 release-notes entry (TODO-89)
    
    Co-authored-by: Cursor <[email protected]>
---
 pages/release-notes/9.5.0.md                       | 60 ++++++++++++++++++---
 pages/topics/10.14c.OpsIntrospectionMixins.md      | 43 +++++++++++++--
 .../10.20c.RestServerRateLimitAndRequestId.md      | 61 ++++++++++++++++++++++
 3 files changed, 151 insertions(+), 13 deletions(-)

diff --git a/pages/release-notes/9.5.0.md b/pages/release-notes/9.5.0.md
index 78c624ac21..4be1010299 100644
--- a/pages/release-notes/9.5.0.md
+++ b/pages/release-notes/9.5.0.md
@@ -2664,14 +2664,15 @@ method on the host, leave the rest unmounted.
   `@RestOp(method="*")` so every HTTP verb hits the same code path.
 - **`BasicAdminResource`** — serves `/admin/threads` (JSON thread dump), 
`/admin/heap`
   (`Runtime` + `MemoryMXBean` stats), `POST /admin/cache/flush` (run 
registered hooks),
-  and `/admin/ratelimit` (registered `RateLimitGuard` beans). Default-deny via 
the new
-  `org.apache.juneau.rest.guard.DenyAllGuard` until the host registers a 
`@Bean RestGuardList`
-  factory; the framework's bean-store override seam **replaces** the entire 
annotation-derived
-  guard list (including the deny-all) with the user-supplied chain. Builder 
methods:
-  `cacheFlush(String, Runnable)`, `cacheFlushAll(Map)`, 
`threadNamePrefixExclude(String...)`.
-  Bucket-level rate-limit inspection (per-key counters) is reserved for a 
follow-on once
-  `RateLimitGuard.Storage` exposes a snapshot SPI; v1 emits configuration only 
and returns
-  `404 Not Found` when no `RateLimitGuard` bean is registered.
+  and `/admin/ratelimit` (registered `RateLimitGuard` beans — both static 
configuration and
+  live per-key bucket snapshot; see the
+  [`RateLimitGuard.Storage.snapshot()` SPI 
extension](#ratelimitguardstorage-snapshot-spi--basicadminresource-enrichment-work-item-89)
+  below). Default-deny via the new `org.apache.juneau.rest.guard.DenyAllGuard` 
until the host
+  registers a `@Bean RestGuardList` factory; the framework's bean-store 
override seam
+  **replaces** the entire annotation-derived guard list (including the 
deny-all) with the
+  user-supplied chain. Builder methods: `cacheFlush(String, Runnable)`,
+  `cacheFlushAll(Map)`, `threadNamePrefixExclude(String...)`. Returns `404 Not 
Found` when no
+  `RateLimitGuard` bean is registered.
 - **`BasicRouteIndexResource`** — serves `/options` and `/routes` (synonyms) 
returning a JSON
   list of every `@RestOp`-annotated method on the host (and on every mixin on 
the host),
   ordered by path. Each entry has `path`, `methods` (single-element list of 
the HTTP method),
@@ -2694,6 +2695,49 @@ tests under both Jetty microservice and Spring Boot.
   `@Rest(guards=...)` or `@RestOp(guards=...)` site that wants the same 
default-deny posture
   with a `@Bean RestGuardList` override seam.
 
+#### `RateLimitGuard.Storage.snapshot()` SPI + `BasicAdminResource` enrichment 
(work item 89)
+
+`org.apache.juneau.rest.guard.RateLimitGuard.Storage` gained an optional 
read-side SPI for
+operator-facing tooling, and `BasicAdminResource`'s `/admin/ratelimit` 
endpoint now uses it to
+emit live per-bucket state alongside the existing static configuration. Purely 
additive — the
+new method is a `default` returning `Map.of()` so external storage backends 
(Redis-backed,
+DynamoDB-backed, etc.) keep compiling unchanged.
+
+- **New `default Map<String, RateLimitGuard.BucketState> snapshot()` on 
`RateLimitGuard.Storage`.**
+  Returns a point-in-time view of every per-key bucket the storage backend 
currently tracks.
+  The default empty-map implementation is the backwards-compatible opt-out for 
storages that
+  can't cheaply enumerate buckets. In-memory implementations SHOULD override.
+- **New `RateLimitGuard.BucketState` record.** Token-bucket-vocabulary
+  per-bucket snapshot: `String key`, `double tokens` (current fractional fill 
level),
+  `int remaining` (`floor(tokens)` — mirrors the `X-RateLimit-Remaining` 
advisory header),
+  `boolean throttled` (convenience flag for `tokens < 1.0`), `Instant 
lastRequest`
+  (wall-clock timestamp of the bucket's last activity).
+- **Bundled `Storage.inMemory()` overrides `snapshot()`.** Walks the internal
+  `ConcurrentHashMap<String,Bucket>` and emits one `BucketState` per entry. 
Each per-bucket
+  read is consistent (synchronized on the bucket); the snapshot as a whole is 
not a global
+  point-in-time consistent view (consistent with the `ConcurrentHashMap` 
weakly-consistent
+  iteration semantics).
+- **`Bucket` gained a `lastWallMillis` field** alongside the existing 
`lastNanos` so
+  `BucketState.lastRequest` is wall-clock accurate (and not skewed by JVM 
suspension /
+  container pause). Costs 8 bytes per bucket (~800 KB worst-case at the 
default 100k-key cap).
+- **New public accessors on `RateLimitGuard`.** `getCapacity()`, 
`getPermitsPerSecond()`,
+  `isXForwardedForAware()`, `getExemptPaths()`, `getStorage()`, and the 
convenience
+  `snapshot()` that delegates to `getStorage().snapshot()`. Expose existing 
builder state for
+  operator-facing dashboards.
+- **`/admin/ratelimit` response shape — `buckets` renamed to `snapshot`, 
`config` enriched.**
+  The 9.5.0 ops-pack mixin (FINISHED-77) emitted
+  `{ "guards": { "<bean-name>": { "config": {"class": "..."}, "buckets": [] } 
} }` with the
+  empty `buckets` array as an explicit placeholder. Now the response is
+  `{ "guards": { "<bean-name>": { "config": {"class": "...", "limit": N,
+  "permitsPerSecond": X, "xForwardedForAware": bool, "exemptPaths": [...] },
+  "snapshot": [ {"key": "...", "tokens": N.N, "remaining": N, "throttled": 
bool,
+  "lastRequest": "..."} ] } } }` — `snapshot` entries are sorted ascending by 
`key` for
+  stable operator-tooling output. Storage backends that don't override
+  `Storage.snapshot()` (e.g. Redis-backed impls) emit `"snapshot": []` for 
their bean entry.
+  The `buckets` field name was an FINISHED-77 placeholder that only ever 
emitted `[]`; the
+  rename to `snapshot` matches the SPI method name and is unlikely to affect 
any real
+  consumer.
+
 #### Server-side SSE Helpers (TODO-62)
 
 `juneau-rest-server` now includes an SSE helper layer for streaming endpoints:
diff --git a/pages/topics/10.14c.OpsIntrospectionMixins.md 
b/pages/topics/10.14c.OpsIntrospectionMixins.md
index d94132e393..17aa91a720 100644
--- a/pages/topics/10.14c.OpsIntrospectionMixins.md
+++ b/pages/topics/10.14c.OpsIntrospectionMixins.md
@@ -17,7 +17,7 @@ configure them via a `@Bean` factory, and leave the rest 
unmounted.
 | Mixin | Default `paths` | Default behavior | Why it exists |
 |---|---|---|---|
 | 
[`BasicEchoResource`](/site/apidocs/org/apache/juneau/rest/ops/BasicEchoResource.html)
 | `/echo/*`, `/debug/echo/*` | `404 Not Found` until `Debug` is enabled. When 
debug-on, returns a JSON body reflecting the inbound method, path, query 
string, headers (sensitive ones redacted), query params, attributes, and 
bounded body capture. | Round-trip request introspection — invaluable for 
diagnosing proxy / mTLS / auth-header issues without spinning up a packet 
capture. |
-| 
[`BasicAdminResource`](/site/apidocs/org/apache/juneau/rest/ops/BasicAdminResource.html)
 | `/admin/threads`, `/admin/heap`, `/admin/cache/flush`, `/admin/ratelimit` | 
`403 Forbidden` until the host registers a `@Bean RestGuardList`. Once 
unlocked: `GET /admin/threads` (JSON thread dump), `GET /admin/heap` (Runtime + 
MemoryMXBean stats), `POST /admin/cache/flush` (run registered hooks), `GET 
/admin/ratelimit` (configured `RateLimitGuard` beans). | JVM operational 
visibility on a deploye [...]
+| 
[`BasicAdminResource`](/site/apidocs/org/apache/juneau/rest/ops/BasicAdminResource.html)
 | `/admin/threads`, `/admin/heap`, `/admin/cache/flush`, `/admin/ratelimit` | 
`403 Forbidden` until the host registers a `@Bean RestGuardList`. Once 
unlocked: `GET /admin/threads` (JSON thread dump), `GET /admin/heap` (Runtime + 
MemoryMXBean stats), `POST /admin/cache/flush` (run registered hooks), `GET 
/admin/ratelimit` (registered `RateLimitGuard` beans — config + live per-key 
bucket snapshot). | [...]
 | 
[`BasicRouteIndexResource`](/site/apidocs/org/apache/juneau/rest/ops/BasicRouteIndexResource.html)
 | `/options`, `/routes` | JSON list of every `@RestOp`-annotated method on the 
host (and its mixins), excluding `@OpSwagger(ignore=true)` ops and itself. Each 
entry: `path`, `methods`, `summary`, `description`, `deprecated`. | 
Machine-readable navigation index for tooling that needs a non-Swagger view of 
the URL surface (smoke-test scripts, auto-generated nav, etc.). |
 
 All endpoints across the pack carry
@@ -175,10 +175,43 @@ canonical auth chain when it ships will unlock the admin 
paths automatically.
   callers that want async semantics own the threading model. Unknown names are 
silently
   ignored (404-on-unknown would leak the registered hook set).
 * `GET /admin/ratelimit` — JSON map keyed by bean name listing every registered
-  
[`RateLimitGuard`](/site/apidocs/org/apache/juneau/rest/guard/RateLimitGuard.html).
 Returns
-  `404 Not Found` when no `RateLimitGuard` bean is registered. Bucket-level 
inspection
-  (per-key counters) is reserved for a follow-on once `RateLimitGuard.Storage` 
exposes a
-  snapshot SPI; v1 emits configuration only.
+  
[`RateLimitGuard`](/site/apidocs/org/apache/juneau/rest/guard/RateLimitGuard.html).
 Each
+  entry has two sub-fields: `config` (the guard's static configuration: 
`class`, `limit`,
+  `permitsPerSecond`, `xForwardedForAware`, `exemptPaths`) and `snapshot` (a 
sorted-by-key
+  array of 
[`BucketState`](/site/apidocs/org/apache/juneau/rest/guard/RateLimitGuard.BucketState.html)
+  entries describing every per-key bucket the storage backend currently 
tracks). Each
+  `BucketState` carries `key`, `tokens` (fractional fill level), `remaining` 
(integer tokens
+  available — mirrors the `X-RateLimit-Remaining` advisory header), 
`throttled` (convenience
+  flag for `tokens < 1.0`), and `lastRequest` (wall-clock `Instant` of last 
activity).
+  Returns `404 Not Found` when no `RateLimitGuard` bean is registered. Storage 
backends that
+  don't override 
[`Storage.snapshot()`](/site/apidocs/org/apache/juneau/rest/guard/RateLimitGuard.Storage.html#snapshot())
+  (e.g. Redis-backed impls that can't cheaply enumerate buckets) emit 
`"snapshot": []` for
+  their bean entry — the SPI method is `default` returning `Map.of()` so 
external
+  `Storage` implementations stay backwards-compatible. See [Rate-Limiting and 
Request-Id
+  Propagation § Inspecting bucket state at 
runtime](/docs/topics/RestServerRateLimitAndRequestId#inspecting-bucket-state-at-runtime)
+  for the full snapshot SPI contract.
+
+  Example response shape:
+
+  ```json
+  {
+    "guards": {
+      "rateLimit": {
+        "config": {
+          "class": "org.apache.juneau.rest.guard.RateLimitGuard",
+          "limit": 100,
+          "permitsPerSecond": 1.67,
+          "xForwardedForAware": false,
+          "exemptPaths": ["/healthz", "/livez", "/readyz"]
+        },
+        "snapshot": [
+          {"key": "203.0.113.42", "tokens": 23.0, "remaining": 23, 
"throttled": false, "lastRequest": "2026-05-26T14:00:42Z"},
+          {"key": "203.0.113.99", "tokens": 0.5, "remaining": 0, "throttled": 
true, "lastRequest": "2026-05-26T14:00:58Z"}
+        ]
+      }
+    }
+  }
+  ```
 
 ### `BasicRouteIndexResource`
 
diff --git a/pages/topics/10.20c.RestServerRateLimitAndRequestId.md 
b/pages/topics/10.20c.RestServerRateLimitAndRequestId.md
index 38d1653be1..4853f69ae6 100644
--- a/pages/topics/10.20c.RestServerRateLimitAndRequestId.md
+++ b/pages/topics/10.20c.RestServerRateLimitAndRequestId.md
@@ -142,6 +142,67 @@ The optional `whenLimitExceeded(BiConsumer<RestRequest, 
RateLimitInfo>)` callbac
 
 Use this hook for metrics (`statsd`, `micrometer`), structured logging, or 
pushing throttle events onto a queue for downstream analysis.
 
+### Inspecting bucket state at runtime
+
+`RateLimitGuard.Storage` exposes an optional read-side SPI for operator-facing 
tooling: <a 
href="/site/apidocs/org/apache/juneau/rest/guard/RateLimitGuard.Storage.html#snapshot()"
 target="_blank">`default Map<String, BucketState> snapshot()`</a>. It returns 
a point-in-time view of every per-key bucket the storage backend currently 
tracks, so operators can answer not only "what is the rate-limit policy?" but 
also "which clients are currently throttled and at what fill level?".
+
+The method is `default` returning `Map.of()`, which makes the addition 
**purely additive and binary-compatible**: external storage backends 
(Redis-backed, DynamoDB-backed, JDBC-backed, …) that don't enumerate buckets 
cheaply keep compiling unchanged and inherit the empty-map default. The bundled 
in-memory storage overrides `snapshot()` to walk its 
`ConcurrentHashMap<String,Bucket>` and emit one `BucketState` per entry.
+
+#### `BucketState` record
+
+Each entry in the returned map is a <a 
href="/site/apidocs/org/apache/juneau/rest/guard/RateLimitGuard.BucketState.html"
 target="_blank">`BucketState`</a> record describing a single per-key bucket:
+
+| Field            | Meaning                                                   
                                   |
+|------------------|----------------------------------------------------------------------------------------------|
+| `key()`          | The per-request key the bucket is registered under (IP, 
user id, custom).                    |
+| `tokens()`       | The bucket's current fractional fill level (continuously 
refilled at `permitsPerSecond`).    |
+| `remaining()`    | Integer tokens currently available — `floor(tokens)`. 
Mirrors `X-RateLimit-Remaining`.       |
+| `throttled()`    | `true` when `tokens < 1.0` (the next request from this 
key would be rejected).               |
+| `lastRequest()`  | Wall-clock 
[`Instant`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Instant.html)
 of the bucket's last activity. |
+
+Vocabulary is intentionally token-bucket-shaped: the underlying model is 
continuous-refill, so there is no discrete "window start" to report. `tokens` 
(the fill level) and `lastRequest` (the last activity) together fully describe 
the bucket's state.
+
+The `lastRequest` field is wall-clock-accurate because each `Bucket` carries a 
`lastWallMillis` field updated alongside the existing monotonic 
`System.nanoTime()` value. The 8-byte-per-bucket overhead is trivial (~800 KB 
worst-case at the default 100k-key cap) and avoids the drift you'd get from 
mapping a `nanoTime` delta back to wall-clock under JVM suspension or container 
pause.
+
+#### `RateLimitGuard` accessors
+
+For programmatic access — operator dashboards, custom admin endpoints, 
integration tests — `RateLimitGuard` exposes the configured state plus a 
snapshot convenience:
+
+| Method                       | Returns                                       
       |
+|------------------------------|------------------------------------------------------|
+| `getCapacity()`              | The bucket capacity (burst).                  
       |
+| `getPermitsPerSecond()`      | The steady-state refill rate.                 
       |
+| `isXForwardedForAware()`     | Whether `X-Forwarded-For`-aware keying is 
enabled.   |
+| `getExemptPaths()`           | The set of paths that bypass the guard.       
       |
+| `getStorage()`               | The configured `Storage` backend.             
       |
+| `snapshot()`                 | Convenience for `getStorage().snapshot()`.    
       |
+
+```java
+var guard = RateLimitGuard.create()
+    .permitsPerSecond(100)
+    .burst(200)
+    .build();
+
+// ... requests flow through ...
+
+Map<String, BucketState> live = guard.snapshot();
+for (var b : live.values())
+    if (b.throttled())
+        log.warn("client {} is throttled (last seen {})", b.key(), 
b.lastRequest());
+```
+
+#### Consistency contract
+
+Each per-bucket read inside `InMemoryStorage.snapshot()` is consistent (the 
`Bucket` accessors are `synchronized` on the bucket monitor), but the snapshot 
as a whole is **not** a global point-in-time consistent view — the iterator 
walks `ConcurrentHashMap` weakly-consistent entries, and concurrent 
`tryAcquire(...)` calls on other buckets may interleave with the walk. This is 
the right trade-off for operator visibility: contention-free under load, 
accurate per-key, and inconsistent only i [...]
+
+The returned `Map` is immutable (`Map.copyOf(...)`); callers can safely pass 
it across threads.
+
+#### `/admin/ratelimit` endpoint
+
+The Juneau ops/introspection mixin pack ships 
[`BasicAdminResource`](/docs/topics/OpsIntrospectionMixins#basicadminresource), 
whose `/admin/ratelimit` endpoint already exposes `snapshot()` over HTTP for 
every registered `RateLimitGuard` bean — alongside the enriched `config` block 
(`class`, `limit`, `permitsPerSecond`, `xForwardedForAware`, `exemptPaths`). 
See the [`BasicAdminResource` 
section](/docs/topics/OpsIntrospectionMixins#basicadminresource) of the 
ops-pack page for the full JSON [...]
+
+Storage backends that don't override `snapshot()` emit `"snapshot": []` for 
their bean entry on `/admin/ratelimit` — the SPI's default-empty contract is 
honored end-to-end.
+
 ## `RequestIdFilter` — `X-Request-Id` mint / honor / echo
 
 <a href="/site/apidocs/org/apache/juneau/rest/filter/RequestIdFilter.html" 
target="_blank">RequestIdFilter</a> is a thin pre-call filter that:

Reply via email to