Skip to content

Bound concurrent /api/v1/... requests and cache the history response - #122

Merged
LarsLaskowski merged 3 commits into
mainfrom
claude/issue-108-8ugm5b
Aug 22, 2026
Merged

Bound concurrent /api/v1/... requests and cache the history response#122
LarsLaskowski merged 3 commits into
mainfrom
claude/issue-108-8ugm5b

Conversation

@LarsLaskowski

@LarsLaskowski LarsLaskowski commented Aug 22, 2026

Copy link
Copy Markdown
Owner

📖 Description

GET /api/v1/metrics/history deep-copies every retained history ring buffer and
JSON-encodes the result on every request, while Collector.History() holds the
collector's lock. With no concurrency cap and no authentication by default, a
request flood from anywhere on the LAN (a compromised device, a misconfigured
integration, a buggy retry loop) can multiply this cost enough to starve the
collector's own tick on a single-core Pi Zero — the monitoring tool stops
monitoring exactly when the machine is under load. Setting api_key does not
help: withAPIKey runs before the handler and doesn't bound concurrency.

Re-verified the issue against the current code before implementing (per the
issue's own note that it was AI-drafted): Collector.History() and the
middleware chain in internal/httpapi/server.go still match the description.

This PR implements the two measures the issue recommends as highest value
(items 1 and 2); the optional per-client rate limiter (item 3) is skipped, as
recommended, to avoid a second runtime dependency for little gain. I also
checked #112 (the related ?since=/conditional-request issue) — it has no PR
and isn't in progress, so this implementation doesn't overlap with active work
there; a future ?since= implementation can build on top of the cache added
here.

  • Concurrency cap (withMaxInFlight, internal/httpapi/middleware.go):
    a shared semaphore of capacity defaultMaxInFlight = 16 bounds concurrent
    processing across the four /api/v1/... routes. A request beyond the limit
    gets 503 Service Unavailable with Retry-After: 1 instead of queuing
    indefinitely. /healthz and the static dashboard are deliberately not
    wrapped, so a monitoring system can still tell the process is alive while
    the API is shedding load. Kept as a constant per the issue's guidance, not
    a new config option.
  • Response caching (internal/httpapi/handlers.go,
    internal/collector/collector.go): the retained history only changes once
    per fastTick, so Collector.HistoryGeneration() exposes a counter bumped
    once per tick, and handleHistory caches the encoded JSON response,
    reusing it until the generation moves on. Concurrent pollers between ticks
    now share one deep-copy-and-encode instead of each paying for their own.

🎫 Issues

Closes #108

👩‍💻 Reviewer Notes

  • internal/httpapi/middleware.go / middleware_test.go: the semaphore
    middleware and its tests.
  • internal/httpapi/handlers.go: handleHistory's cache logic — note it now
    uses json.Marshal directly instead of the shared writeJSON helper, so it
    can hold the cached bytes rather than the History struct.
  • internal/collector/collector.go: historyGen is incremented under the
    same lock fastTick already holds; HistoryGeneration() takes a read lock.
  • MetricsProvider gained HistoryGeneration() uint64fakeMetrics in
    handlers_test.go was updated accordingly, plus a historyCalls counter to
    assert the cache actually skips re-encoding on a cache hit.

Smoke test: make run, then for i in $(seq 1 20); do curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/v1/metrics/history & done; wait — expect a mix of 200s and a few 503s once 16 concurrent requests are in flight, and curl localhost:8080/healthz still returns 200 throughout.

📑 Test Plan

Per docs/TESTS.md, new tests added to internal/httpapi/middleware_test.go
using explicit channel synchronization (no time.Sleep):

  1. TestWithMaxInFlight_RejectsBeyondLimit — fills the semaphore with
    blocked requests, asserts exactly one more gets 503 + Retry-After: 1.
  2. TestWithMaxInFlight_ReleasesPermit — regression test for a missing
    defer func() { <-sem }(): after in-flight requests complete, a
    subsequent request must still succeed.
  3. TestWithMaxInFlight_AllowsTrafficBelowLimit — sequential requests below
    the limit all return 200.
  4. TestHealthz_BypassesMaxInFlight — fills the shared semaphore to its real
    defaultMaxInFlight capacity and asserts /healthz still returns 200
    (while a sibling /api/v1/... request returns 503, confirming the fill
    actually exercised the limiter).

And to internal/httpapi/handlers_test.go:

  1. TestHandleHistory_CachesUntilGenerationChanges — repeated requests at
    the same generation reuse the cached response (History() called once);
    a request after the generation advances gets fresh data (History()
    called again).

go build ./..., go vet ./..., and go test ./... -race -cover all pass
locally (all packages ≥88% coverage, unchanged from before this PR).
golangci-lint run reports 0 issues.

✅ Checklist

General

  • I have added/updated tests for my changes (go test ./... -race -cover passes locally).
  • go vet ./... and golangci-lint run are clean.
  • I have tested my changes.
  • I have read the CONTRIBUTING documentation and followed the project's code style guidelines.
  • I have updated ARCHITECTURE.md if this changes a documented design decision.

REST API / configuration / packaging

  • I have updated docs/API.md to reflect a REST API change (new "Rate limiting" section documenting the 503/Retry-After behavior).
  • No breaking change to /api/v1/... response shapes, or a new API version (/api/v2/...) was introduced instead — the JSON body is unchanged, only a new possible status code is added.
  • I have updated README.md / packaging/pimonitor.example.yaml to reflect a new or changed configuration option. — not applicable, defaultMaxInFlight is deliberately a constant, not a config option, per the issue.
  • I have updated packaging/install.sh or the systemd units — not applicable, no packaging/installation change.

⏭ Next Steps

claude added 3 commits August 22, 2026 09:17
GET /api/v1/metrics/history deep-copies every retained history ring
buffer and JSON-encodes the result on every request, while holding the
collector's lock. With no concurrency cap, enough concurrent callers
(another device on the LAN, a misconfigured integration, a buggy retry
loop) can starve the collector's own tick on a single-core Pi Zero, so
the monitoring tool stops monitoring exactly when the machine is under
load. Setting api_key does not prevent this: withAPIKey runs before the
handler and does not bound concurrency.

- withMaxInFlight (internal/httpapi/middleware.go) bounds concurrent
  processing across the four /api/v1/... routes via a shared semaphore
  (defaultMaxInFlight = 16, a constant rather than a config option to
  keep the config surface small). Requests beyond the limit get 503
  with Retry-After: 1 rather than queuing indefinitely. /healthz and
  the static dashboard assets are not wrapped, so liveness checks keep
  working while the API sheds load.
- handleHistory now caches its encoded JSON response, invalidated by a
  generation counter (Collector.historyGen) the collector bumps once
  per fastTick. Concurrent pollers between ticks reuse one encode
  instead of each paying for their own deep copy and serialization.

Both mitigations are documented in docs/API.md (new "Rate limiting"
section), docs/ARCHITECTURE.md (HTTP layer), and SECURITY.md.

Closes #108
…path

The SonarCloud quality gate on PR #122 failed at 79.4% coverage on new
code (80% required): Collector.HistoryGeneration() was never called
directly by a collector test, and handleHistory's json.Marshal error
branch was never exercised (the fake MetricsProvider's history always
marshaled successfully). Add a NaN-valued HistoryPoint to trigger the
marshal failure deterministically.
Fixes a SonarCloud finding (go:S1192, CRITICAL) on PR #122: the
"Content-Type" header name literal was duplicated three times in
internal/httpapi/handlers.go.
@sonarqubecloud

Copy link
Copy Markdown

@LarsLaskowski
LarsLaskowski merged commit ca4714e into main Aug 22, 2026
5 checks passed
@LarsLaskowski
LarsLaskowski deleted the claude/issue-108-8ugm5b branch August 22, 2026 10:36
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.

No request throttling: /api/v1/metrics/history is an expensive unauthenticated endpoint

2 participants