Replay every unanimous upstream status, not just 4xx #27
Reference in New Issue
Block a user
Delete Branch "benvin/replay-upstream-errors"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
openvoxdb answers the same malformed query with 400 on
/nodesbut 500 on/facts, and/metricswith a flat 403, so replaying only 4xx made behaviour depend on the route; meta and metrics bypassed the replay entirely./pdb/meta,/metricsand the pass-through path through that same rule.application/json;charset=utf-8.Review findings.
Blocking
serveByOwner/proxyOrderedregression on the per-certname owner routes (/pdb/query/v4/{nodes,factsets,catalogs}/<certname>[...]).ownerFirststill returns all backends with the owner first;proxyOrderedstill tries them in order and short-circuits on the first 2xx (unchanged). But when nobody 2xx's, it now needsupstreamOutcome/unanimity across all of them instead of replaying whichever answered first (the owner). That breaks the one thing this route exists for — "answers a path keyed on one certname from the backend that owns that certname" (certname.go:42-52) — whenever the owner's real answer differs from the non-owner's incidental one. Reproduced on this branch: owner backend faults with a real 500 ("boom"), non-owner legitimately doesn't hold the certname (its own 404) → pre-PR replayed the owner's 500 verbatim; this branch returns a generic502 all backends failed, discarding the owner's actual answer. Plausible in the live migration topology (a node present on only one backend, which is having a transient issue). Untested: the newTestProxyUnmerged_ReplayRuleonly covers the plain pass-through route (resourcesPath, configured order), notserveByOwner/certname routes — no test pins the owner-precedence behavior against this rule change.Non-blocking
clientRefusal()is a flatstatus < 500, so a unanimous 403 (previously excluded, treated as a backend-side fault) is now fully exempt from the degraded-round counter and stale fallback. Checked live: today's unanimous 403 on/metrics/v2/listis openvoxdb's own metrics-lockdown policy on both backends, not a TLS/cert issue, so exempting it is correct for this deployment. The risk is only if pdbmux ever authenticates to backends with a client cert that can fail — that would now masquerade as "query answered, estate's own 4xx" rather than a degraded round. Worth a code comment now that the old cert-vs-data rationale (previously inclientShaped's doc) was dropped rather than carried intoclientRefusal./queryview is the only real behavioural delta" undersells it. Checked voxpupuli/puppetboard: every other view (nodes, facts, catalogs, metrics, index, radiator, reports, dailychart) uses plainget_or_abort, which doesabort(e.response.status_code)for any HTTPError — so those pages will now show Flask's default 403/404/429/500/503 page instead of always 502 for the newly-widened cases, app-wide, not just on/query. Probably desirable (matches "indistinguishable from PuppetDB"), but it's more than one view. Separately, "no retry behaviour changes anywhere" checked out: neither pypuppetdb (api/base.py, barerequests.Session(), unconditionalraise_for_status(), no retry adapter) nor puppetboard has any status-based retry logic to begin with.jsonContentType's comment cites openvoxdb'shttp.clj:55-66(simple-utf8-ctype?) to justify the no-space spelling, but that function only affects Accept-header negotiation, not response serialization — and openvoxdb's actualrr/content-typecall (bothmainand the 8.9.0 tag) passes"application/json; charset=utf-8"with a space. Live-checked anyway: both real backends and current pdbmux answer with no space on the wire (application/json;charset=utf-8), so the chosen constant is empirically correct — the citation just doesn't explain why.["=","name"]on/nodes→ unanimous 400 "= requires exactly two arguments"; on/facts→ unanimous 500 with an empty body. Current production pdbmux already replays the first correctly but returns502 all backends failedfor the second — a live, reproducible instance of exactly the inconsistency this PR fixes.engine.clj:2988-2996's unguarded(nth user-query-clause 2)andquery_eng.clj:571-583's catch list (IllegalArgumentException,ExceptionInfowith:kindin#{:puppetlabs.puppetdb.query/invalid ::dr/unrecognized-ast-syntax}→ 400 viahttp/error-response, anything else uncaught → 500) both check out against openvoxdb source.errAllBackendsFailed/502) — correct, covered by the existing "no backends" case. A backend excluded by health gating leaves the survivor's answer "unanimous" by itself, but that's inherent to howfanOut/liveBackendsalready worked pre-PR (unanimity has always been computed over the queried set, not the configured set) — this PR just extends it to 5xx, not a new risk.replayableStatus) and reran: every new "unanimous status replayed" test fails as expected, so none are vacuous.go test -race ./...,golangci-lint run ./..., andpre-commit run --all-filesall pass clean on the branch.benvin/drop-fact-caching) also touchesserver.go,cache_test.go,upstream_test.goandREADME.md— flagging the merge-overlap risk per the review brief, not fixing it here.Targeted review:
72380f2,85c9088Verdict: correct, no blocking issues. Both commits do what they claim; the fix resolves the owner-masking bug described in the PR without touching backend selection.
Verified
proxyOrderedloop (unchanged shape, only theaskOrderparam is new): owner 2xx short-circuits immediately (never reachesoutcome); owner 4xx/5xx wins over a non-owner's differing status becauseownerOutcomescansrepliesin ask order andreplies[0]is always the owner's slot; owner silent (nil entry) correctly falls through to the first non-nil replayable fallback reply; all-silent and owner-silent+fallback-silent both correctly 502. A 3xx from the owner is (consistent withpeerOutcome/replayableStatus) not replayable and falls through like a silent reply — unchanged pre-existing behavior, not a regression.proxyUnmerged→askPeers,serveByOwner→askOwnerFirst; no other fan-out route was missed (meta.go,server.go:538both correctly renamed topeerOutcome, no leftover references to the deletedupstreamOutcome).ownerFirst()can. Worth a one-line comment onserveFirstHoldernoting this was considered and rejected, so it doesn't look like an oversight next time someone reads it.85c9088is comment-only (no lines outside comments changed).ownerFirst()body is byte-for-byte unchanged fromeeb4405; the diff only threadsaskOwnerFirstthrough the existing call. Confirmed live againstpuppetdb.k8s.syd1.au.unkin.netandpdbmux.k8s.syd1.au.unkin.netthat the 404 shape ("No information is known about node <certname>")ownerFirst/serveByOwnerrely on to treat a non-owner's reply as "not mine" matches production.TestCertnameRoutes_OwnerAnswerWins— ran it against121bfac(pre-fix) with 3/5 subtests failing exactly as claimed (owner-errors-vs-404, owner-and-non-owner-error-differently, owner-unreachable-fallback-answers all got a 502 instead of the owner's/fallback's real status), and against5207a79(pre-PR) with all 5 passing — genuinely proves regression, not a new requirement.go test -race ./...,go vet,gofmt -l,golangci-lint run, andpre-commit run --all-filesare all clean at head. ThevoxBackendextension (perNodeStatus/perNodeBody/perNodeDead) is opt-in (guarded byisCertnameRoute+ non-zero checks) and doesn't touch the pre-existing/nodesand/factsetsfake handlers, so it doesn't weaken existing coverage.Non-blocking
TestCertnameRoutes_OneBackendDown's stated rationale is wrong.a.fail = trueintercepts every path including/nodes, soais excluded fromfreshnessMap'saliveset entirely andbuildFreshnessattributesh1.example.nettobdirectly —bis the freshness-resolved owner from the start, not a fallback. Combined withproxyOrdered's early-return-on-first-success,ais never even contacted. The test still passes and still proves something real (fan-out survives one dead backend), just not "the fallback is exercised only if a is skipped" as its comment claims — no fallback path is exercised here at all; that's what the newowner unreachable, fallback answerssubtest inTestCertnameRoutes_OwnerAnswerWins(usingperNodeDead, which correctly keepsaresolvable as owner via/nodeswhile only killing its per-certname reply) actually covers. Checked the other 16 call sites of.fail = true/.fail, b.failacross the suite — none of the others make an ownership/freshness claim in their comments, so this false premise is isolated to this one pre-existing test (not touched by these two commits). Pre-existing, so not this PR's fault, but worth a follow-up to fix the comment or make it actually exercise fallback.clientRefusalcomment asserts the/metrics/v2/list403 is "that backend's own policy rather than an authentication failure" without a source citation — that endpoint appears to be served by trapperkeeper-metrics rather than anything inOpenVoxProject/openvoxdbitself, so I could not confirm it against openvoxdb source (no file:line to check). Doesn't affect correctness of the code, just an unsourced assumption in a comment that reads like the other sourced ones.PR body
Compliant: 800/800 chars, 7 lines, present-tense bullets, final-state only, no review history or session/person references.