mirror of
https://github.com/livekit/livekit.git
synced 2026-09-25 06:55:16 +00:00
The registry was doing three jobs at once: fencing worker epochs by id, listing a deployment's candidates, and holding its merged route table. The last two are per-deployment state, so it keyed them on (api key, agent name, deployment) and grew a tenancy concept that only an embedder can actually define. Cloud has to lie to it, passing a project id in a field named APIKey. Split them. Scope is one deployment's serving state and stores no identity at all; whoever embeds the package keys a map of scopes however its own tenancy works, and hands the front a resolved one. Registry keeps only the worker-id fence, which is genuinely node-wide: worker ids are server-issued, so an epoch is superseded wherever it was scoped. The front loses its registry, its SingleKeyFallback and FallbackRequest: the resolver now returns the scope and a fallback already curried on the deployment, plus an ok that carries the 401-vs-503 split the empty api key used to encode. routeTable drops its key and takes the scope's logger, so identity is curried in rather than stored. pkg/service takes ownership of the "api key is the tenant" rule, which is true there and nowhere else, and of releasing a scope once nothing holds it. Behavior is unchanged, including serving public routes to an unauthenticated caller when one configured key or one attached tenant makes the key unambiguous.
637 lines
20 KiB
Go
637 lines
20 KiB
Go
// Copyright 2026 LiveKit, Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package endpoint
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand/v2"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/livekit/livekit-server/pkg/agent/endpoint/router"
|
|
"github.com/livekit/livekit-server/pkg/agent/endpoint/wire"
|
|
"github.com/livekit/protocol/livekit"
|
|
"github.com/livekit/protocol/logger"
|
|
)
|
|
|
|
const (
|
|
// PathPrefix is the public route namespace: /agents/{agent_name}/{deployment}/{path...}
|
|
PathPrefix = "/agents/"
|
|
|
|
// MaxPathLength caps the escaped route path. Matching runs before the
|
|
// request head is sized, so this is the only bound on it.
|
|
MaxPathLength = 8 << 10
|
|
|
|
// responseHeadTimeout bounds the wait for the worker's response head. Bodies
|
|
// (SSE, long streams) are unbounded; the head never legitimately takes this
|
|
// long.
|
|
responseHeadTimeout = 90 * time.Second
|
|
|
|
// maxAttempts bounds worker retries per request
|
|
maxAttempts = 3
|
|
|
|
// maxRequestIDLen bounds the client's idempotence token: it reaches this
|
|
// node's logs and the worker's, so it cannot be unbounded.
|
|
maxRequestIDLen = 128
|
|
|
|
// maxInformationalHeads bounds 1xx responses before the final head, so a
|
|
// worker cannot hold a client open by trickling them forever.
|
|
maxInformationalHeads = 8
|
|
|
|
maxResponseHeadSize = 1 << 20
|
|
responseBufSize = 8 << 10
|
|
)
|
|
|
|
var (
|
|
errNotEndpointPath = errors.New("endpoint: not an agent endpoint path")
|
|
errMalformedPath = errors.New("endpoint: malformed agent endpoint path")
|
|
errPathTooLong = errors.New("endpoint: agent endpoint path too long")
|
|
errRequestHeadTooLarge = errors.New("endpoint: request head too large")
|
|
errProtocolSwitch = errors.New("endpoint: worker switched protocols on an HTTP exchange")
|
|
errTooManyInformational = errors.New("endpoint: too many informational responses")
|
|
errBadStatus = errors.New("endpoint: response status out of range")
|
|
errHeadTooLarge = errors.New("endpoint: response head too large")
|
|
)
|
|
|
|
// AccessLevel is how far a request's caller is trusted. Callers compare against
|
|
// it, so a new level must be inserted at its correct rank.
|
|
type AccessLevel int
|
|
|
|
const (
|
|
// AccessNone presented no credential.
|
|
AccessNone AccessLevel = iota
|
|
// AccessCredentialed presented a valid token carrying no agent-endpoint
|
|
// grant for the addressed agent and deployment.
|
|
AccessCredentialed
|
|
// AccessGranted presented a token whose agent-endpoint grant covers the
|
|
// addressed agent and deployment.
|
|
AccessGranted
|
|
)
|
|
|
|
func (a AccessLevel) String() string {
|
|
switch a {
|
|
case AccessNone:
|
|
return "none"
|
|
case AccessCredentialed:
|
|
return "credentialed"
|
|
case AccessGranted:
|
|
return "granted"
|
|
default:
|
|
return fmt.Sprintf("%d", int(a))
|
|
}
|
|
}
|
|
|
|
// Access is what the front knows about a request's caller, together with the
|
|
// serving state it resolved to. The front never keys anything itself: whatever
|
|
// scopes a request - a tenant, an api key, a project - is resolved by the
|
|
// embedder and arrives here already looked up.
|
|
type Access struct {
|
|
// Scope is the deployment's serving state this request is placed against.
|
|
// nil means no worker here holds it; Fallback may still place it elsewhere.
|
|
Scope *Scope
|
|
// Fallback serves the request elsewhere (e.g. a multi-node relay), already
|
|
// curried on the deployment it resolved. nil means local misses are final.
|
|
Fallback Fallback
|
|
Level AccessLevel
|
|
}
|
|
|
|
// AccessResolver maps an inbound request, plus the agent and deployment its URL
|
|
// addresses, to the caller's access. ok is false when the request cannot be
|
|
// placed at all - no credential, or an unknown tenant - and the front
|
|
// challenges.
|
|
type AccessResolver func(r *http.Request, agentName, deployment string) (access Access, ok bool)
|
|
|
|
type Front struct {
|
|
params FrontParams
|
|
pools *bridgePools
|
|
}
|
|
|
|
// Identity resolves the agent and deployment a request addresses. Reporting
|
|
// false leaves them to the URL.
|
|
type Identity func(r *http.Request) (agentName, deployment string, ok bool)
|
|
|
|
// FrontParams configures a Front. Fields are read on every request once the
|
|
// Front is serving, so none may change after construction.
|
|
type FrontParams struct {
|
|
ResolveAccess AccessResolver
|
|
Logger logger.Logger
|
|
Identity Identity
|
|
}
|
|
|
|
func NewFront(params FrontParams) *Front {
|
|
params.Logger = params.Logger.WithComponent("agents.endpoint")
|
|
return &Front{params: params, pools: newBridgePools()}
|
|
}
|
|
|
|
// Fallback serves a request elsewhere (e.g. a multi-node relay); it reports
|
|
// whether a response was written. Returning false falls back to the local
|
|
// status mapping. It is curried on the deployment it was resolved for, so it
|
|
// carries no scope arguments.
|
|
type Fallback func(w http.ResponseWriter, r *http.Request, level AccessLevel) bool
|
|
|
|
// writeUnavailable writes a 503 with a Retry-After hint: no local worker can
|
|
// serve the request and no fallback placed it elsewhere.
|
|
func (f *Front) writeUnavailable(w http.ResponseWriter, msg string) {
|
|
w.Header().Set("Retry-After", "1")
|
|
http.Error(w, msg, http.StatusServiceUnavailable)
|
|
}
|
|
|
|
func (f *Front) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
ep, err := splitEndpointPath(r.URL)
|
|
if err != nil {
|
|
if errors.Is(err, errMalformedPath) {
|
|
http.Error(w, "bad request path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if errors.Is(err, errPathTooLong) {
|
|
http.Error(w, "uri too long", http.StatusRequestURITooLong)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
agentName, deployment, path, escPath := ep.agentName, ep.deployment, ep.path, ep.escPath
|
|
if f.params.Identity != nil {
|
|
// must precede resolveAccess, which may consume its source headers
|
|
if name, dep, ok := f.params.Identity(r); ok {
|
|
agentName, deployment = name, dep
|
|
}
|
|
}
|
|
|
|
reqID, ok := requestID(r)
|
|
if !ok {
|
|
http.Error(w, "invalid X-Request-Id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
access, ok := f.params.ResolveAccess(r, agentName, deployment)
|
|
if !ok {
|
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tbl := access.Scope.routeTable()
|
|
if tbl == nil && access.Fallback == nil {
|
|
f.writeUnavailable(w, "no workers available for deployment")
|
|
return
|
|
}
|
|
|
|
mask := methodMask(r.Method)
|
|
granted := access.Level >= AccessGranted
|
|
matched, partial, denied := f.matchDeployment(access.Scope, tbl, path, mask, granted)
|
|
// no exact match: if only the trailing-slash alternate matches a registered
|
|
// route, normalize the path to that form and serve it directly (no client
|
|
// redirect). The exact form is tried first, so a route registered with a
|
|
// trailing slash is served as-is; this only rewrites a slash mismatch toward
|
|
// the registered form. When the request must be relayed, the serving node
|
|
// runs this same normalization, so no redirect is ever emitted.
|
|
if len(matched) == 0 && !partial && !denied {
|
|
if alt, altEsc, ok := slashAlternatePaths(tbl, path, escPath, mask); ok {
|
|
path, escPath = alt, altEsc
|
|
matched, partial, denied = f.matchDeployment(access.Scope, tbl, path, mask, granted)
|
|
}
|
|
}
|
|
if len(matched) == 0 && access.Fallback != nil {
|
|
// nothing local matched: hand off to the multi-node fallback (relay to a
|
|
// node holding the deployment) before the local status mapping. The
|
|
// serving node's relay listener installs no fallback of its own, so a
|
|
// relayed request is served or errored there and never re-relays.
|
|
if access.Fallback(w, r, access.Level) {
|
|
return
|
|
}
|
|
if tbl == nil && !denied && !partial {
|
|
f.writeUnavailable(w, "no workers available for deployment")
|
|
return
|
|
}
|
|
}
|
|
if len(matched) == 0 {
|
|
switch {
|
|
case denied:
|
|
// access does not vary across candidates, so one verdict covers them all
|
|
if access.Level >= AccessCredentialed {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
} else {
|
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
|
}
|
|
case partial:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
default:
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}
|
|
return
|
|
}
|
|
|
|
var bodyConsumed atomic.Int64
|
|
a := &attempt{
|
|
req: r,
|
|
escPath: escPath,
|
|
target: requestTarget(escPath, r.URL.RawQuery),
|
|
requestID: reqID,
|
|
granted: granted,
|
|
pools: f.pools,
|
|
}
|
|
a.body = &countingReader{r: r.Body, n: &bodyConsumed}
|
|
a.preamble = a.newPreamble()
|
|
a.refreshTimeout()
|
|
if err := a.buildHead(); err != nil {
|
|
if errors.Is(err, errRequestHeadTooLarge) {
|
|
http.Error(w, "request header fields too large", http.StatusRequestHeaderFieldsTooLarge)
|
|
return
|
|
}
|
|
f.params.Logger.Debugw("agent endpoint rejected a request head", "error", err, "requestID", reqID)
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
attempted := make(map[*Registration]bool)
|
|
for range maxAttempts {
|
|
picked := pickWorker(matched, attempted)
|
|
if picked == nil {
|
|
break
|
|
}
|
|
attempted[picked.reg] = true
|
|
|
|
a.before = bodyConsumed.Load()
|
|
a.refreshTimeout()
|
|
// the preamble is re-serialized per attempt, so this reaches only the
|
|
// worker it was set for
|
|
a.preamble.Route = picked.raw
|
|
switch f.bridge(w, a, picked.reg) {
|
|
case bridgeDone:
|
|
return
|
|
case bridgeAbort:
|
|
// the head is on the wire already, so nothing can report the failure in
|
|
// band. This must reach net/http to abort the response; anything that
|
|
// recovers it completes the body.
|
|
panic(http.ErrAbortHandler)
|
|
}
|
|
}
|
|
|
|
// the route matched locally but nothing served it (matches draining or
|
|
// conn-less, or every attempt failed before writing): the fallback may hold
|
|
// capacity elsewhere. Safe exactly while no request bytes were consumed -
|
|
// reaching this point implies it, since consuming attempts are never
|
|
// retryable.
|
|
if bodyConsumed.Load() == 0 && access.Fallback != nil {
|
|
if access.Fallback(w, r, access.Level) {
|
|
return
|
|
}
|
|
}
|
|
|
|
f.writeUnavailable(w, "no worker could serve the request")
|
|
}
|
|
|
|
// matchDeployment resolves a path against the deployment's merged route table.
|
|
// matched is the dispatch test: a route can be decided while nothing is left to
|
|
// serve it, and an undecidable table dispatches with no route at all.
|
|
func (f *Front) matchDeployment(scope *Scope, tbl *routeTable, path string, mask router.Mask, granted bool) (matched []routeWorker, partial, denied bool) {
|
|
if tbl == nil {
|
|
return nil, false, false
|
|
}
|
|
var res router.Result
|
|
matched, _, res, denied = tbl.match(path, mask, granted)
|
|
switch res {
|
|
case router.ResultPartial:
|
|
partial = true
|
|
case router.ResultOverBudget:
|
|
// no route was decided, so its Public flag is unknown and only a grant
|
|
// can clear the request
|
|
if !granted {
|
|
denied = true
|
|
break
|
|
}
|
|
for _, reg := range scope.Candidates() {
|
|
matched = append(matched, routeWorker{reg: reg})
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// slashAlternatePaths looks for the trailing-slash alternate of path in the
|
|
// deployment's table, returning the decoded and escaped forms to retry. Both
|
|
// forms come out of the same transform.
|
|
func slashAlternatePaths(tbl *routeTable, path, escPath string, mask router.Mask) (string, string, bool) {
|
|
if tbl == nil || path == "/" {
|
|
return path, escPath, false
|
|
}
|
|
var alt, altEsc string
|
|
if strings.HasSuffix(path, "/") {
|
|
// %2F decodes to a slash without being a separator, so the alternate
|
|
// applies only while the slash is literal in escPath too
|
|
if !strings.HasSuffix(escPath, "/") {
|
|
return path, escPath, false
|
|
}
|
|
alt, altEsc = strings.TrimSuffix(path, "/"), strings.TrimSuffix(escPath, "/")
|
|
} else {
|
|
alt, altEsc = path+"/", escPath+"/"
|
|
}
|
|
if !tbl.serves(alt, mask) {
|
|
return path, escPath, false
|
|
}
|
|
return alt, altEsc, true
|
|
}
|
|
|
|
// endpointPath is a request split into its routing components.
|
|
type endpointPath struct {
|
|
agentName string
|
|
deployment string
|
|
// path is decoded, for manifest matching
|
|
path string
|
|
// escPath keeps the client's encoding, for the request line the worker gets
|
|
escPath string
|
|
}
|
|
|
|
// splitEndpointPath splits /agents/{agent_name}/{deployment}/{path...} from the
|
|
// ESCAPED path. The worker is handed a re-serialized request line, so the target
|
|
// must keep the client's encoding: a decoded %3F or %2F re-emits as a real '?'
|
|
// or '/' and changes which resource the worker routes to.
|
|
func splitEndpointPath(u *url.URL) (endpointPath, error) {
|
|
rest, ok := strings.CutPrefix(u.EscapedPath(), PathPrefix)
|
|
if !ok {
|
|
return endpointPath{}, errNotEndpointPath
|
|
}
|
|
rawAgentName, rest, found := strings.Cut(rest, "/")
|
|
if !found || rawAgentName == "" {
|
|
return endpointPath{}, errNotEndpointPath
|
|
}
|
|
rawDeployment, escPath, found := strings.Cut(rest, "/")
|
|
if !found {
|
|
escPath = ""
|
|
}
|
|
if rawDeployment == "" {
|
|
return endpointPath{}, errNotEndpointPath
|
|
}
|
|
escPath = "/" + escPath
|
|
|
|
if len(escPath) > MaxPathLength {
|
|
return endpointPath{}, errPathTooLong
|
|
}
|
|
|
|
agentName, err1 := url.PathUnescape(rawAgentName)
|
|
deployment, err2 := url.PathUnescape(rawDeployment)
|
|
path, err3 := url.PathUnescape(escPath)
|
|
if err1 != nil || err2 != nil || err3 != nil {
|
|
return endpointPath{}, errMalformedPath
|
|
}
|
|
// "_" and "%5F" both land here; neither is a registrable name
|
|
// (IsReservedAgentName)
|
|
if agentName == UnnamedAgentSegment {
|
|
agentName = ""
|
|
}
|
|
return endpointPath{agentName: agentName, deployment: deployment, path: path, escPath: escPath}, nil
|
|
}
|
|
|
|
// pickWorker chooses a worker by the power of two choices: sample two eligible
|
|
// declarations at random and take the one with fewer in-flight streams.
|
|
// Eligible = not already attempted, has a live session, not draining.
|
|
func pickWorker(workers []routeWorker, ignore map[*Registration]bool) *routeWorker {
|
|
var eligible []routeWorker
|
|
for _, w := range workers {
|
|
if ignore[w.reg] || !w.reg.HasSession() || w.reg.IsDraining() {
|
|
continue
|
|
}
|
|
eligible = append(eligible, w)
|
|
}
|
|
if len(eligible) == 0 {
|
|
return nil
|
|
}
|
|
return &eligible[p2c(eligible, func(w routeWorker) int { return w.reg.InflightStreams() })]
|
|
}
|
|
|
|
// p2c returns the index of the less-loaded of two distinct random draws from
|
|
// items, which must be non-empty.
|
|
func p2c[T any](items []T, load func(T) int) int {
|
|
n := len(items)
|
|
if n == 1 {
|
|
return 0
|
|
}
|
|
i := rand.IntN(n)
|
|
j := rand.IntN(n - 1)
|
|
if j >= i { // fold to a distinct second draw
|
|
j++
|
|
}
|
|
if load(items[i]) <= load(items[j]) {
|
|
return i
|
|
}
|
|
return j
|
|
}
|
|
|
|
// bridgeOutcome is what one attempt against one worker concluded.
|
|
type bridgeOutcome int
|
|
|
|
const (
|
|
// nothing reached the client; another worker may still serve it
|
|
bridgeRetry bridgeOutcome = iota
|
|
// a response, or an error standing in for one, reached the client
|
|
bridgeDone
|
|
// the response was committed and cannot be completed
|
|
bridgeAbort
|
|
)
|
|
|
|
// bridge runs one attempt against one worker.
|
|
func (f *Front) bridge(w http.ResponseWriter, a *attempt, reg *Registration) bridgeOutcome {
|
|
ctx := a.req.Context()
|
|
stream, err := reg.OpenStream(ctx)
|
|
if err != nil {
|
|
return bridgeRetry // no session/capacity here; try another worker
|
|
}
|
|
defer stream.Close()
|
|
|
|
stop := context.AfterFunc(ctx, func() {
|
|
stream.Reset(livekit.AgentHttp_HSR_ABORT, "client disconnected")
|
|
})
|
|
defer stop()
|
|
|
|
// serialize the request into the stream concurrently with response reading:
|
|
// directions are independent (full duplex within the stream)
|
|
writeErrCh := make(chan error, 1)
|
|
go func() {
|
|
err := a.writeRequest(stream)
|
|
if err == nil {
|
|
err = stream.CloseWrite()
|
|
} else {
|
|
// fail fast: the worker is waiting for bytes that will never come
|
|
stream.Reset(livekit.AgentHttp_HSR_ABORT, "request write failed")
|
|
}
|
|
writeErrCh <- err
|
|
}()
|
|
|
|
lim := &headLimiter{r: stream, n: maxResponseHeadSize}
|
|
br := f.pools.getReader(lim)
|
|
defer f.pools.putReader(br)
|
|
|
|
resp, err := a.readResponse(w, br, lim, stream)
|
|
if err != nil {
|
|
err = completionError(err)
|
|
if !a.retryable(err) {
|
|
f.params.Logger.Warnw("agent endpoint request failed", err,
|
|
"workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID)
|
|
writeGatewayError(w, err)
|
|
return bridgeDone
|
|
}
|
|
// join the request writer before another attempt touches the shared
|
|
// body reader (retries are bodyless per the table, so this is prompt)
|
|
stream.Reset(livekit.AgentHttp_HSR_ABORT, "retrying elsewhere")
|
|
<-writeErrCh
|
|
return bridgeRetry
|
|
}
|
|
// resp.Body must not be Closed: net/http's Close drains whatever the head
|
|
// declared and the body has not delivered, blocking on a stream that is
|
|
// about to be reset. stream.Close owns the underlying resource.
|
|
|
|
// a response head arrived: from here every failure is surfaced
|
|
copyResponseHeaders(w.Header(), resp.Header)
|
|
w.WriteHeader(resp.StatusCode)
|
|
a.committed = true
|
|
|
|
rc := http.NewResponseController(w)
|
|
bufp := f.pools.getBuf()
|
|
buf := *bufp
|
|
defer f.pools.putBuf(bufp)
|
|
for {
|
|
n, rerr := resp.Body.Read(buf)
|
|
if n > 0 {
|
|
if _, werr := w.Write(buf[:n]); werr != nil {
|
|
stream.Reset(livekit.AgentHttp_HSR_ABORT, "client write failed")
|
|
return bridgeDone
|
|
}
|
|
_ = rc.Flush()
|
|
}
|
|
if rerr == io.EOF {
|
|
break
|
|
}
|
|
if rerr != nil {
|
|
// never expose a clean-looking short body
|
|
return f.aborted(rerr, reg, a, writeErrCh)
|
|
}
|
|
}
|
|
// framing complete; the sender may still report a short body in trailers
|
|
if ce := wire.CompletionFromTrailers(resp.Trailer); ce != nil {
|
|
return f.aborted(ce, reg, a, writeErrCh)
|
|
}
|
|
return bridgeDone
|
|
}
|
|
|
|
// aborted logs why a committed response cannot be completed.
|
|
func (f *Front) aborted(err error, reg *Registration, a *attempt, writeErrCh <-chan error) bridgeOutcome {
|
|
f.logAborted(err, reg, a)
|
|
select {
|
|
case werr := <-writeErrCh:
|
|
f.params.Logger.Debugw("request write result after response failure", "error", werr)
|
|
default:
|
|
}
|
|
return bridgeAbort
|
|
}
|
|
|
|
func (f *Front) logAborted(err error, reg *Registration, a *attempt) {
|
|
var ce *wire.CompletionError
|
|
if errors.As(err, &ce) {
|
|
f.params.Logger.Infow("agent endpoint response aborted",
|
|
"workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID,
|
|
"completion", string(ce.Completion), "reason", ce.Reason)
|
|
return
|
|
}
|
|
f.params.Logger.Infow("agent endpoint response aborted",
|
|
"workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID, "error", err)
|
|
}
|
|
|
|
// completionError normalizes a failure into the protocol's outcome vocabulary.
|
|
// A peer reset code becomes the outcome; anything else leaves dispatch unknown.
|
|
func completionError(err error) error {
|
|
var sre *StreamResetError
|
|
if errors.As(err, &sre) {
|
|
return &wire.CompletionError{Completion: wire.CompletionFromResetCode(sre.Code)}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// writeGatewayError maps a terminal state to a status for a request whose
|
|
// response never reached the client.
|
|
func writeGatewayError(w http.ResponseWriter, err error) {
|
|
var ce *wire.CompletionError
|
|
if errors.As(err, &ce) && ce.Completion == wire.CompletionTimeout {
|
|
http.Error(w, "gateway timeout", http.StatusGatewayTimeout)
|
|
return
|
|
}
|
|
if errors.Is(err, os.ErrDeadlineExceeded) {
|
|
http.Error(w, "gateway timeout", http.StatusGatewayTimeout)
|
|
return
|
|
}
|
|
http.Error(w, "bad gateway", http.StatusBadGateway)
|
|
}
|
|
|
|
// bridgePools holds the per-request scratch one Front reuses: a body buffer and
|
|
// the buffered reader http.ReadResponse parses through.
|
|
type bridgePools struct {
|
|
bodyBuf sync.Pool
|
|
readers sync.Pool
|
|
}
|
|
|
|
func newBridgePools() *bridgePools {
|
|
return &bridgePools{
|
|
bodyBuf: sync.Pool{New: func() any { b := make([]byte, wire.BodyChunkSize); return &b }},
|
|
readers: sync.Pool{New: func() any { return bufio.NewReaderSize(nil, responseBufSize) }},
|
|
}
|
|
}
|
|
|
|
func (p *bridgePools) getBuf() *[]byte { return p.bodyBuf.Get().(*[]byte) }
|
|
func (p *bridgePools) putBuf(b *[]byte) { p.bodyBuf.Put(b) }
|
|
|
|
func (p *bridgePools) getReader(r io.Reader) *bufio.Reader {
|
|
br := p.readers.Get().(*bufio.Reader)
|
|
br.Reset(r)
|
|
return br
|
|
}
|
|
|
|
// putReader drops the stream reference so a pooled reader never pins a dead one.
|
|
func (p *bridgePools) putReader(br *bufio.Reader) {
|
|
br.Reset(nil)
|
|
p.readers.Put(br)
|
|
}
|
|
|
|
// headLimiter caps the bytes a response head may make this node buffer. release
|
|
// lifts the cap once the head is parsed; the body behind it is unbounded.
|
|
type headLimiter struct {
|
|
r io.Reader
|
|
n int64 // remaining head budget; negative once the head has been parsed
|
|
}
|
|
|
|
func (h *headLimiter) Read(p []byte) (int, error) {
|
|
if h.n == 0 {
|
|
return 0, errHeadTooLarge
|
|
}
|
|
if h.n > 0 && int64(len(p)) > h.n {
|
|
p = p[:h.n]
|
|
}
|
|
n, err := h.r.Read(p)
|
|
if h.n > 0 {
|
|
h.n -= int64(n)
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func (h *headLimiter) release() { h.n = -1 }
|