Serve the owner's own answer on the per-certname routes
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was canceled

Unanimity is the right rule for a fan-out of peers, but the per-certname
routes are not one: a backend that does not hold the certname answers 404
to say so, not to disagree, so requiring it to agree turned the owner's
real 500 into a 502 that described neither backend.

- Add askOrder, which says whether a set of backends was asked as peers
  or owner-first, and resolve each round's replies under its own rule.
- Serve the first backend that answered on owner-routed paths, so an
  unreachable owner still falls back rather than collapsing to 502.
- Keep unanimity for the merged, meta, metrics and pass-through routes.
- Cover the owner routes: owner errors against a non-owner 404, both
  erroring differently, an unreachable owner, and a non-owner error
  behind the owner's 200.
- Record what clientRefusal's 4xx exemption assumes about client certs.
This commit is contained in:
2026-09-13 14:11:44 +10:00
parent 121bfacc2f
commit 72380f27e6
6 changed files with 182 additions and 21 deletions
+11 -9
View File
@@ -535,7 +535,7 @@ func (s *Server) serveFirstHolder(w http.ResponseWriter, r *http.Request) {
if len(alive) == 0 {
// Unanimity is the whole answer here too: every backend saying 404 means
// nobody holds the report, while one silent backend leaves that unknown.
s.writeUpstreamError(w, upstreamOutcome(backendUpstreamErrors(results)))
s.writeUpstreamError(w, peerOutcome(backendUpstreamErrors(results)))
return
}
for _, res := range alive {
@@ -922,19 +922,21 @@ func (s *Server) queryBackend(ctx context.Context, b Backend, path string, param
return recs, total, err
}
// The record shape is unknown, so a union would be guesswork: the first 2xx wins and the first error response is replayed when none succeeds.
// The record shape is unknown, so a union would be guesswork: the first 2xx wins, and every backend was asked the same question, so only a reply they all gave is replayed.
func (s *Server) proxyUnmerged(w http.ResponseWriter, r *http.Request) {
s.proxyOrdered(w, r, s.cfg.Backends)
s.proxyOrdered(w, r, s.cfg.Backends, askPeers)
}
// proxyOrdered asks backends in the given order, which is what decides the answer when more than one of them holds the path.
func (s *Server) proxyOrdered(w http.ResponseWriter, r *http.Request, backends []Backend) {
refusals := make([]*upstreamError, 0, len(backends))
// proxyOrdered asks backends in the given order, which is what decides the
// answer when more than one of them holds the path. order says why they are in
// that order, which is what decides whose reply is served when none answers 2xx.
func (s *Server) proxyOrdered(w http.ResponseWriter, r *http.Request, backends []Backend, order askOrder) {
replies := make([]*upstreamError, 0, len(backends))
for _, b := range backends {
resp, err := s.passThrough(r, b)
if err != nil {
s.log.Printf("warning: backend %q pass-through failed for %s: %v", b.Name, r.URL.Path, err)
refusals = append(refusals, nil)
replies = append(replies, nil)
continue
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
@@ -946,9 +948,9 @@ func (s *Server) proxyOrdered(w http.ResponseWriter, r *http.Request, backends [
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
refusals = append(refusals, newUpstreamError(resp.StatusCode, resp.Header.Get("Content-Type"), body))
replies = append(replies, newUpstreamError(resp.StatusCode, resp.Header.Get("Content-Type"), body))
}
s.writeUpstreamError(w, upstreamOutcome(refusals))
s.writeUpstreamError(w, order.outcome(replies))
}
func (s *Server) passThrough(r *http.Request, b Backend) (*http.Response, error) {