Skip to content

fix(pg-pkg): give each client its own client_version budget - #390

Merged
rubenhensen merged 2 commits into
mainfrom
feat/388-per-client-version-cap
Aug 31, 2026
Merged

fix(pg-pkg): give each client its own client_version budget#390
rubenhensen merged 2 commits into
mainfrom
feat/388-per-client-version-cap

Conversation

@dobby-coder

@dobby-coder dobby-coder Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes #388.

postguard_clients kept one process-wide set of seen client_version values, capped at 64 and shared by every client. That budget does not cover the fleet: @e4a/pg-js has published 55 versions and E4A.PostGuard 9, which is 64 between those two SDKs before either add-in or the CLI appears. A busy deployment fills the set from live traffic and then reports every later real release as other until the process restarts, and other is only alertable while nothing legitimate lands there.

This was decided on #384 and asked for in a comment there, but that PR merged as written.

What changed

All of it in pg-pkg/src/middleware/metrics.rs.

  • SEEN_CLIENT_VERSIONS is now a HashMap<String, HashSet<String>>: one set per client, still 64 each.
  • The map is keyed on the post-allowlist client value, which is a closed set of 8 (pg-js, pg-dotnet, pg4ol, pg4tb, cli, pg-cli, unknown, other), so the worst case is bounded at 8 x 64. Keying it on the raw header field would hand out a fresh 64-slot budget for every client name an attacker invents.
  • client_version_label takes that allowlisted value as its first argument, so collect_metrics computes allowlisted(client, KNOWN_CLIENTS) before it builds the label array rather than inside it.
  • unknown and other as client_version values still consume no slot, in any bucket. A request whose client resolves to other or unknown spends that bucket like any other.
  • The doc comment on the cap now gives the per-client reasoning instead of citing the @e4a/pg-js version count as headroom.

Untouched: the host/client allowlists and their contents, the unknown/other semantics, the shape gate on client_version, the five labels, server.rs, util.rs, cryptify/src/metrics.rs, COMPATIBILITY.md, .github/workflows/.

Tests

Both in the existing test_get_metrics module, driving the real actix app as its neighbours do.

  • test_client_version_cap_holds, rewritten: 70 distinct well-shaped versions for one client, then that client's distinct client_version values read off the exposition text are 64 plus other.
  • test_a_full_client_does_not_spend_another_clients_budget, new: fill pg-js's bucket to 64, send a fresh well-shaped version as pg-dotnet, assert it is emitted exactly. Same host, path and status on both, so the client is the only dimension that differs.

Test 1 passes under both designs, so test 2 is the only thing that distinguishes them. Reverting the map to a single shared set locally, keeping both tests:

test middleware::metrics::tests::test_client_version_cap_holds ... ok
test middleware::metrics::tests::test_a_full_client_does_not_spend_another_clients_budget ... FAILED

assertion `left == right` failed
  left: {"other"}
 right: {"0.6.0"}

Restored afterwards; the branch holds the map.

Acceptance check

cargo test -p pg-pkg                     # 71 + 3 + 2 + 5 passed, 0 failed
cargo clippy -p pg-pkg -- -D warnings    # clean
cargo fmt -p pg-pkg -- --check           # clean

Reviewed by dobby: the code-comments rule (clean) plus a correctness pass over the whole diff, which raised 2 non-blocking findings, both fixed in bdd2305 — the per-bucket caveat restored to the cap's doc comment, and a regression test added for the budget-key choice (red under the raw-key mutation the rest of the suite passes) — cargo test green across the workspace (pg-pkg 72/3/2/5, 0 failures), cargo clippy -- -D warnings and cargo fmt --check clean — approve.

`postguard_clients` held one process-wide set of seen `client_version`
values capped at 64, shared by every client. That budget does not cover
the fleet: `@e4a/pg-js` has published 55 versions and `E4A.PostGuard` 9,
so one SDK's releases can spend the slots the other's real releases then
miss, and every later release reads as `other` until the process
restarts. `other` is only alertable while nothing legitimate lands there.

One set per client now, still 64 each. The map is keyed on the
post-allowlist `client` value, which is a closed set of 8, so the worst
case is bounded at 8 x 64; keying it on the raw header field would hand
out a fresh budget for every client name an attacker invents.

`unknown` and `other` as `client_version` values still consume no slot,
in any bucket, and a request whose `client` resolves to `other` or
`unknown` spends that bucket like any other.

The cap test now reads one client's versions off the exposition, and a
second test fills one client's bucket and asserts a different client's
fresh version is still emitted exactly. That second test is what fails
if the map goes back to a shared set.

Refs #388
@dobby-coder
dobby-coder Bot requested a review from rubenhensen August 31, 2026 07:42
Two points from review, both on the cap's design record.

The rewritten doc comment dropped two facts that are still true per
bucket: why 64 suffices at all (a process sees the versions in live use,
not every version ever released) and the residual failure mode (once a
bucket fills, that client's later real releases read as `other` until a
restart). COMPATIBILITY.md's deprecation step 3 reads this metric as
"no traffic", so a filled bucket is a wrong answer to that question, and
this comment was the only place the caveat was written down.

The comment singles out the key choice as what bounds memory, but no
test held it: keying the budget on the raw header field again passes the
whole suite. test_invented_client_names_share_one_budget closes that —
it is red under exactly that mutation and green as shipped, while both
existing tests pass either way because they only use allowlisted client
names, where the raw and allowlisted keys coincide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rubenhensen
rubenhensen merged commit da46273 into main Aug 31, 2026
67 checks passed
@rubenhensen
rubenhensen deleted the feat/388-per-client-version-cap branch August 31, 2026 08:56
@github-actions github-actions Bot mentioned this pull request Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pg-pkg: postguard_clients' 64-version cap is one budget for the whole fleet, so real releases will read as other

1 participant