121bfacc2f
openvoxdb does not reserve 5xx for its own faults: the same malformed query is a 400 on /nodes and a 500 on /facts, and /metrics answers a flat 403, so a 4xx-only replay rule made pdbmux's behaviour depend on the route. The meta and metrics handlers held their own copy of the gateway error and bypassed the replay entirely. - Replay any status from 400 up that every backend agreed on, with the backend's own body and content type. - Keep 502 for backends disagreeing on the status, or a backend that answered nothing at all. - Route /pdb/meta, /metrics and the pass-through path through the same rule as the merged query handlers. - Count a unanimous 5xx as a failed round and let it fall back to a stale cache entry; only a unanimous 4xx stays exempt from both. - Answer successful queries with openvoxdb's application/json;charset=utf-8.
125 lines
4.8 KiB
Go
125 lines
4.8 KiB
Go
//go:build e2e
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Every backend gets the same query, so a query none of them can answer is the
|
|
// client's mistake rather than an outage. openvoxdb explains what is wrong with
|
|
// it; pdbmux has to hand that explanation and its status back instead of a 502
|
|
// saying the estate is down.
|
|
func TestRejectedQuerySurfacesUpstreamStatus(t *testing.T) {
|
|
ctx := context.Background()
|
|
for _, tc := range []struct{ name, path, q string }{
|
|
{"merged", nodesPath, `["=","bogus","x"]`},
|
|
{"combined", nodesPath, `["extract",[["function","count"]],["=","bogus","x"]]`},
|
|
{"union", reportsPath, `["=","bogus","x"]`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
params := query(tc.q)
|
|
status, upstream := h.a.queryRaw(ctx, t, tc.path, params)
|
|
if !replayableStatus(status) || status >= 500 {
|
|
t.Fatalf("backend %s answered HTTP %d for %s, which is not the client error this test needs: %s",
|
|
h.a.name, status, tc.q, upstream)
|
|
}
|
|
if other, _ := h.b.queryRaw(ctx, t, tc.path, params); other != status {
|
|
t.Fatalf("backends answered %d and %d, so the rejection is not unanimous", status, other)
|
|
}
|
|
|
|
_, before := healthz(t)
|
|
resp := rawGet(t, tc.path, params)
|
|
if resp.status != status {
|
|
t.Fatalf("pdbmux answered HTTP %d, want the upstream %d: %s", resp.status, status, resp.body)
|
|
}
|
|
if got, want := strings.TrimSpace(string(resp.body)), strings.TrimSpace(string(upstream)); got != want {
|
|
t.Errorf("pdbmux body = %q, want openvoxdb's own explanation %q", got, want)
|
|
}
|
|
for _, b := range []*backend{h.a, h.b} {
|
|
if strings.Contains(string(resp.body), b.url) {
|
|
t.Errorf("replayed body names backend %s: %q", b.name, resp.body)
|
|
}
|
|
}
|
|
|
|
// A refused query is not degraded service, so it must leave the merged
|
|
// fan-out's own health counters where it found them.
|
|
_, after := healthz(t)
|
|
if after.Query.PartialRounds != before.Query.PartialRounds || after.Query.Partial {
|
|
t.Errorf("query health = %+v, want %+v unchanged by a refused query", after.Query, before.Query)
|
|
}
|
|
if after.Status != "ok" {
|
|
t.Errorf("/healthz = %q after a refused query, want ok", after.Status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// openvoxdb answers the same malformed clause with 400 on /nodes and 500 on
|
|
// /facts — engine.clj's rewrite-fact-query runs an unguarded nth only for the
|
|
// facts entity. Both are the backends' real answer, so both have to reach the
|
|
// client rather than one of them becoming a 502.
|
|
func TestUnanimousServerErrorSurfacesUpstreamStatus(t *testing.T) {
|
|
ctx := context.Background()
|
|
params := query(`["=","name"]`)
|
|
|
|
status, upstream := h.a.queryRaw(ctx, t, factsPath, params)
|
|
if status < 500 {
|
|
t.Skipf("backend %s answered HTTP %d for the arity bug this test needs", h.a.name, status)
|
|
}
|
|
if other, _ := h.b.queryRaw(ctx, t, factsPath, params); other != status {
|
|
t.Fatalf("backends answered %d and %d, so the failure is not unanimous", status, other)
|
|
}
|
|
|
|
resp := rawGet(t, factsPath, params)
|
|
if resp.status != status {
|
|
t.Fatalf("pdbmux answered HTTP %d, want the upstream %d: %s", resp.status, status, resp.body)
|
|
}
|
|
if got, want := strings.TrimSpace(string(resp.body)), strings.TrimSpace(string(upstream)); want != "" && got != want {
|
|
t.Errorf("pdbmux body = %q, want openvoxdb's own explanation %q", got, want)
|
|
}
|
|
for _, b := range []*backend{h.a, h.b} {
|
|
if strings.Contains(string(resp.body), b.url) {
|
|
t.Errorf("replayed body names backend %s: %q", b.name, resp.body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// /metrics and /pdb/meta had their own copy of the gateway error, so a status
|
|
// both backends agreed on never reached the client on those routes.
|
|
func TestUnanimousMetricsStatusIsReplayed(t *testing.T) {
|
|
ctx := context.Background()
|
|
for _, path := range []string{"/metrics/v1/mbeans", "/metrics/v2/list"} {
|
|
t.Run(path, func(t *testing.T) {
|
|
status, _ := h.a.queryRaw(ctx, t, path, nil)
|
|
if status < 400 {
|
|
t.Skipf("%s answers HTTP %d on this estate, so there is nothing to replay", path, status)
|
|
}
|
|
if other, _ := h.b.queryRaw(ctx, t, path, nil); other != status {
|
|
t.Fatalf("backends answered %d and %d, so the status is not unanimous", status, other)
|
|
}
|
|
if resp := rawGet(t, path, nil); resp.status != status {
|
|
t.Fatalf("pdbmux answered HTTP %d, want the upstream %d: %s", resp.status, status, resp.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A client cannot tell pdbmux from a PuppetDB by the content type either.
|
|
func TestSuccessContentTypeMatchesUpstream(t *testing.T) {
|
|
ctx := context.Background()
|
|
params := url.Values{"limit": {"1"}}
|
|
_, _ = h.a.queryRaw(ctx, t, nodesPath, params)
|
|
|
|
resp := rawGet(t, nodesPath, params)
|
|
if resp.status != 200 {
|
|
t.Fatalf("status %d: %s", resp.status, resp.body)
|
|
}
|
|
if got := resp.header.Get("Content-Type"); got != "application/json;charset=utf-8" {
|
|
t.Errorf("Content-Type = %q, want openvoxdb's own %q", got, "application/json;charset=utf-8")
|
|
}
|
|
}
|