Bound concurrent /api/v1/... requests and cache the history response - #122
Merged
Conversation
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.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



📖 Description
GET /api/v1/metrics/historydeep-copies every retained history ring buffer andJSON-encodes the result on every request, while
Collector.History()holds thecollector'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_keydoes nothelp:
withAPIKeyruns 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 themiddleware chain in
internal/httpapi/server.gostill 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 PRand 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 addedhere.
withMaxInFlight,internal/httpapi/middleware.go):a shared semaphore of capacity
defaultMaxInFlight = 16bounds concurrentprocessing across the four
/api/v1/...routes. A request beyond the limitgets
503 Service UnavailablewithRetry-After: 1instead of queuingindefinitely.
/healthzand the static dashboard are deliberately notwrapped, 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.
internal/httpapi/handlers.go,internal/collector/collector.go): the retained history only changes onceper
fastTick, soCollector.HistoryGeneration()exposes a counter bumpedonce per tick, and
handleHistorycaches 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 semaphoremiddleware and its tests.
internal/httpapi/handlers.go:handleHistory's cache logic — note it nowuses
json.Marshaldirectly instead of the sharedwriteJSONhelper, so itcan hold the cached bytes rather than the
Historystruct.internal/collector/collector.go:historyGenis incremented under thesame lock
fastTickalready holds;HistoryGeneration()takes a read lock.MetricsProvidergainedHistoryGeneration() uint64—fakeMetricsinhandlers_test.gowas updated accordingly, plus ahistoryCallscounter toassert the cache actually skips re-encoding on a cache hit.
Smoke test:
make run, thenfor 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 of200s and a few503s once 16 concurrent requests are in flight, andcurl localhost:8080/healthzstill returns200throughout.📑 Test Plan
Per
docs/TESTS.md, new tests added tointernal/httpapi/middleware_test.gousing explicit channel synchronization (no
time.Sleep):TestWithMaxInFlight_RejectsBeyondLimit— fills the semaphore withblocked requests, asserts exactly one more gets
503+Retry-After: 1.TestWithMaxInFlight_ReleasesPermit— regression test for a missingdefer func() { <-sem }(): after in-flight requests complete, asubsequent request must still succeed.
TestWithMaxInFlight_AllowsTrafficBelowLimit— sequential requests belowthe limit all return
200.TestHealthz_BypassesMaxInFlight— fills the shared semaphore to its realdefaultMaxInFlightcapacity and asserts/healthzstill returns200(while a sibling
/api/v1/...request returns503, confirming the fillactually exercised the limiter).
And to
internal/httpapi/handlers_test.go:TestHandleHistory_CachesUntilGenerationChanges— repeated requests atthe 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 ./..., andgo test ./... -race -coverall passlocally (all packages ≥88% coverage, unchanged from before this PR).
golangci-lint runreports 0 issues.✅ Checklist
General
go test ./... -race -coverpasses locally).go vet ./...andgolangci-lint runare clean.ARCHITECTURE.mdif this changes a documented design decision.REST API / configuration / packaging
docs/API.mdto reflect a REST API change (new "Rate limiting" section documenting the503/Retry-Afterbehavior)./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.README.md/packaging/pimonitor.example.yamlto reflect a new or changed configuration option. — not applicable,defaultMaxInFlightis deliberately a constant, not a config option, per the issue.packaging/install.shor the systemd units — not applicable, no packaging/installation change.⏭ Next Steps
?since=incremental history delivery) is still open and would build naturally on top of the generation counter added here.