Ctrl K
ComunidadGuías…

pkg/k8score/request_deadlines.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package k8score

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
)

const (
	// requestIdleTimeout ends a read when no response bytes have
	// arrived for this long. A transfer that keeps delivering bytes, however
	// slowly, is never cut.
	requestIdleTimeout = time.Minute
	// responseHeaderTimeout bounds the wait for response headers: the
	// apiserver can take a while to start answering a large LIST.
	responseHeaderTimeout = 3 * time.Minute
	// requestCeiling bounds a whole non-watch read, only to stop a
	// request that keeps trickling bytes forever.
	requestCeiling = 15 * time.Minute
	// watchDeadlineGrace is added to a watch's timeoutSeconds. The
	// apiserver ends such a watch itself after timeoutSeconds, so the client
	// deadline only fires when the connection died without a reset — for
	// example a proxy whose upstream dropped silently.
	watchDeadlineGrace = time.Minute
)

// WrapRequestDeadlines returns a rest.Config WrapTransport that ends
// informer-style GETs whose connection stalls. serverPath is the path part of
// the config's Host (for example "/k8s/clusters/c-1" behind a proxy, usually
// empty); it is stripped before a request path is classified, so a prefix that
// looks like an API path can't change the classification.
//
// rest.Config.Timeout cannot do this job: it becomes http.Client.Timeout and
// would also end every long-lived watch, log stream and exec session. Without
// any bound, a LIST whose connection stalls without a TCP reset blocks the
// reflector forever and its store silently stops updating.
//
// A non-watch GET ends when its headers take longer than
// responseHeaderTimeout, when no body bytes arrive for
// requestIdleTimeout, or after requestCeiling in total. A watch
// with timeoutSeconds ends at timeoutSeconds plus watchDeadlineGrace;
// it is never idle-timed, because a healthy watch can go quiet. Each HTTP
// attempt gets its own bounds; client-go may retry an attempt. Exempt:
// non-GET requests, protocol upgrades (exec/attach/port-forward), follow=true
// log streams, the apiserver's long-running subresources (log, exec, attach,
// portforward, proxy), and watches without timeoutSeconds.
func WrapRequestDeadlines(serverPath string) func(http.RoundTripper) http.RoundTripper {
	serverPath = strings.TrimRight(serverPath, "/")
	return func(rt http.RoundTripper) http.RoundTripper {
		return &requestDeadlineRoundTripper{
			next:          rt,
			serverPath:    serverPath,
			idle:          requestIdleTimeout,
			headerTimeout: responseHeaderTimeout,
			ceiling:       requestCeiling,
			watchGrace:    watchDeadlineGrace,
		}
	}
}

type requestDeadlineRoundTripper struct {
	next          http.RoundTripper
	serverPath    string
	idle          time.Duration
	headerTimeout time.Duration
	ceiling       time.Duration
	watchGrace    time.Duration
}

// errRequestEnded is the cancellation cause, and the error a caller sees,
// when one of these bounds ends a read. It is deliberately not a timeout or
// connection error: client-go retries those inside a single Watch call and
// can swallow the last one, while this error reaches the reflector, which
// then lists again.
var errRequestEnded = errors.New("request ended by Radar")

func (t *requestDeadlineRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	bounded, watchDeadline := t.classify(req)
	switch {
	case !bounded:
		return t.next.RoundTrip(req)
	case watchDeadline > 0:
		ctx, cancel := context.WithTimeoutCause(req.Context(), watchDeadline,
			fmt.Errorf("%w: watch still open %s after its timeoutSeconds", errRequestEnded, t.watchGrace))
		resp, err := t.next.RoundTrip(req.WithContext(ctx))
		if err != nil {
			err = endCause(ctx, err)
			cancel()
			return resp, err
		}
		resp.Body = &causeBody{ReadCloser: resp.Body, ctx: ctx, stop: cancel}
		return resp, nil
	}

	ceilingCtx, cancelCeiling := context.WithTimeoutCause(req.Context(), t.ceiling,
		fmt.Errorf("%w: still running after %s", errRequestEnded, t.ceiling))
	ctx, cancel := context.WithCancelCause(ceilingCtx)
	stop := func() {
		cancel(nil)
		cancelCeiling()
	}
	headerTimer := time.AfterFunc(t.headerTimeout, func() {
		cancel(fmt.Errorf("%w: no response headers within %s", errRequestEnded, t.headerTimeout))
	})
	resp, err := t.next.RoundTrip(req.WithContext(ctx))
	headerTimer.Stop()
	if err != nil {
		err = endCause(ctx, err)
		stop()
		return resp, err
	}
	body := &idleTimeoutBody{causeBody: causeBody{ReadCloser: resp.Body, ctx: ctx, stop: stop}, idle: t.idle}
	body.timer = time.AfterFunc(t.idle, func() {
		cancel(fmt.Errorf("%w: no data received for %s", errRequestEnded, t.idle))
	})
	resp.Body = body
	return resp, nil
}

// endCause replaces the context error the HTTP stack reports with the bound
// that caused it, so logs and diagnostics say why the read ended. The original
// error is kept as text only, so the result is not a timeout to client-go.
func endCause(ctx context.Context, err error) error {
	if errors.Is(err, errRequestEnded) {
		return err
	}
	if cause := context.Cause(ctx); errors.Is(cause, errRequestEnded) {
		return fmt.Errorf("%w (%v)", cause, err)
	}
	return err
}

// classify reports whether req gets bounds at all and, for a watch, its total
// deadline (0 for every other bounded read).
func (t *requestDeadlineRoundTripper) classify(req *http.Request) (bounded bool, watchDeadline time.Duration) {
	if req.Method != http.MethodGet || req.Header.Get("Upgrade") != "" {
		return false, 0
	}
	q := req.URL.Query()
	if q.Get("follow") == "true" {
		return false, 0
	}
	subresource, legacyWatch := apiPathSubresource(strings.TrimPrefix(req.URL.Path, t.serverPath))
	if longRunningSubresources[subresource] {
		return false, 0
	}
	if w := q.Get("watch"); w == "true" || w == "1" || legacyWatch {
		secs, err := strconv.ParseInt(q.Get("timeoutSeconds"), 10, 64)
		if err != nil || secs <= 0 {
			return false, 0
		}
		return true, time.Duration(secs)*time.Second + t.watchGrace
	}
	return true, 0
}

// longRunningSubresources are the subresources the apiserver itself treats as
// long-running (no server-side request timeout).
var longRunningSubresources = map[string]bool{
	"log":         true,
	"exec":        true,
	"attach":      true,
	"portforward": true,
	"proxy":       true,
}

// causeBody reports a read ended by one of these bounds with its cause, and
// releases the request's context on Close.
type causeBody struct {
	io.ReadCloser
	ctx  context.Context
	stop func()
}

func (b *causeBody) Read(p []byte) (int, error) {
	n, err := b.ReadCloser.Read(p)
	if err != nil && err != io.EOF {
		err = endCause(b.ctx, err)
	}
	return n, err
}

func (b *causeBody) Close() error {
	err := b.ReadCloser.Close()
	b.stop()
	return err
}

// idleTimeoutBody restarts the idle timer each time a Read delivers bytes, so
// only a body that stops delivering is cancelled. It measures the time between
// the caller's reads, so the caller must read the body steadily; client-go
// reads a response whole before decoding it.
type idleTimeoutBody struct {
	causeBody
	idle  time.Duration
	timer *time.Timer
}

func (b *idleTimeoutBody) Read(p []byte) (int, error) {
	n, err := b.causeBody.Read(p)
	if n > 0 {
		b.timer.Reset(b.idle)
	}
	return n, err
}

func (b *idleTimeoutBody) Close() error {
	b.timer.Stop()
	return b.causeBody.Close()
}

// namespaceSubresources are subresources of a Namespace object, which share
// the /namespaces/{name}/{x} shape with namespaced collections.
var namespaceSubresources = map[string]bool{"status": true, "finalize": true}

// isAPIVersion matches Kubernetes API version names: v1, v2beta1, v1alpha3.
func isAPIVersion(s string) bool {
	if len(s) < 2 || s[0] != 'v' || s[1] < '1' || s[1] > '9' {
		return false
	}
	rest := strings.TrimLeft(s[1:], "0123456789")
	for _, suffix := range []string{"alpha", "beta"} {
		if strings.HasPrefix(rest, suffix) {
			n := strings.TrimPrefix(rest, suffix)
			return n != "" && strings.Trim(n, "0123456789") == ""
		}
	}
	return rest == ""
}

// apiPathSubresource reads an apiserver path the way the apiserver's own
// RequestInfoFactory does — /api/{v}/... or /apis/{group}/{v}/..., an
// optional legacy watch/ prefix, an optional namespaces/{ns}/, then
// resource/name/subresource — and returns the subresource and whether the
// legacy watch prefix was used. Matching by position means a resource or
// object that happens to be named "log" or "proxy" is not mistaken for the
// subresource. path must already have the server's path prefix removed;
// anything that doesn't start at the API root has no subresource.
func apiPathSubresource(path string) (subresource string, legacyWatch bool) {
	parts := strings.Split(strings.Trim(path, "/"), "/")
	var start int
	switch {
	case len(parts) > 2 && parts[0] == "api" && isAPIVersion(parts[1]):
		start = 2
	case len(parts) > 3 && parts[0] == "apis" && isAPIVersion(parts[2]):
		start = 3
	default:
		return "", false
	}
	parts = parts[start:]
	if parts[0] == "watch" {
		legacyWatch = true
		parts = parts[1:]
	}
	if len(parts) > 2 && parts[0] == "namespaces" && !namespaceSubresources[parts[2]] {
		parts = parts[2:]
	}
	if len(parts) > 2 {
		subresource = parts[2]
	}
	return subresource, legacyWatch
}