Files
sultan/pkg/stream/client.go
T
br c8df9d7651 erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
2026-10-01 22:22:13 +02:00

198 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package stream
import (
"context"
"errors"
"fmt"
"io"
"mime"
"net/http"
"time"
)
var (
// ErrUnexpectedResponse: Der Server antwortet nicht mit 200 und text/event-stream.
ErrUnexpectedResponse = errors.New("stream: unerwartete Server-Antwort")
// ErrIdleTimeout: Innerhalb von IdleTimeout kam nichts an, auch kein Keepalive.
ErrIdleTimeout = errors.New("stream: Server schweigt (Idle-Timeout)")
// ErrStreamClosed: Der Server hat den Stream beendet.
ErrStreamClosed = errors.New("stream: vom Server beendet")
)
// Client ist die Agent-Seite des Streams.
type Client struct {
// URL des Stream-Endpunkts.
URL string
// HTTPClient für die Verbindung (Default http.DefaultClient). Er darf
// kein Timeout gesetzt haben, sonst bricht er den langlebigen Stream ab;
// die Erkennung toter Verbindungen übernimmt IdleTimeout.
HTTPClient *http.Client
// Prepare wird vor jedem Verbindungsaufbau mit dem Request aufgerufen,
// z. B. um ihn zu signieren.
Prepare func(*http.Request) error
// IdleTimeout: Kommt so lange gar nichts an, gilt die Verbindung als tot
// (Default 90s = 2 × Server-Keepalive).
IdleTimeout time.Duration
// MinBackoff und MaxBackoff begrenzen die Wartezeit zwischen
// Verbindungsversuchen in Run (Default 1s bzw. 1min).
MinBackoff, MaxBackoff time.Duration
// OnError wird in Run vor jeder Wartezeit mit dem Grund aufgerufen.
OnError func(err error, retryIn time.Duration)
sleep func(ctx context.Context, d time.Duration) error // für Tests ersetzbar
}
func (c *Client) idleTimeout() time.Duration { return orDefault(c.IdleTimeout, 90*time.Second) }
func (c *Client) minBackoff() time.Duration { return orDefault(c.MinBackoff, time.Second) }
func (c *Client) maxBackoff() time.Duration { return orDefault(c.MaxBackoff, time.Minute) }
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return http.DefaultClient
}
// handlerError markiert Fehler des Handlers, damit Run sie nicht als
// Verbindungsproblem behandelt.
type handlerError struct{ err error }
func (e *handlerError) Error() string { return e.err.Error() }
func (e *handlerError) Unwrap() error { return e.err }
// Connect baut genau eine Verbindung auf und ruft handle für jedes Event auf,
// bis die Verbindung endet. Gibt handle einen Fehler zurück, endet Connect
// mit diesem Fehler.
func (c *Client) Connect(ctx context.Context, handle func(Event) error) error {
_, err := c.connect(ctx, handle)
var he *handlerError
if errors.As(err, &he) {
return he.err
}
return err
}
// connect meldet zusätzlich, ob der Stream zustande kam (für das
// Zurücksetzen des Backoffs).
func (c *Client) connect(ctx context.Context, handle func(Event) error) (connected bool, err error) {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.URL, nil)
if err != nil {
return false, err
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
if c.Prepare != nil {
if err := c.Prepare(req); err != nil {
return false, err
}
}
resp, err := c.httpClient().Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("%w: Status %d", ErrUnexpectedResponse, resp.StatusCode)
}
if mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mt != "text/event-stream" {
return false, fmt.Errorf("%w: Content-Type %q", ErrUnexpectedResponse, resp.Header.Get("Content-Type"))
}
idle := c.idleTimeout()
timer := time.AfterFunc(idle, func() { cancel(ErrIdleTimeout) })
defer timer.Stop()
dec := NewDecoder(&activityReader{r: resp.Body, timer: timer, idle: idle})
for {
ev, err := dec.Next()
if err != nil {
if ctx.Err() != nil {
return true, context.Cause(ctx)
}
if errors.Is(err, io.EOF) {
return true, ErrStreamClosed
}
return true, err
}
if err := handle(ev); err != nil {
return true, &handlerError{err}
}
}
}
// activityReader setzt den Idle-Timer bei jedem empfangenen Byte zurück –
// Keepalives zählen also mit.
type activityReader struct {
r io.Reader
timer *time.Timer
idle time.Duration
}
func (a *activityReader) Read(p []byte) (int, error) {
n, err := a.r.Read(p)
if n > 0 {
a.timer.Reset(a.idle)
}
return n, err
}
// Run hält die Verbindung dauerhaft aufrecht: Nach jedem Verbindungsende
// wird mit exponentiellem Backoff neu verbunden; nach einer erfolgreich
// aufgebauten Verbindung beginnt das Backoff wieder bei MinBackoff. Run endet
// nur, wenn ctx endet (ctx.Err()) oder handle einen Fehler liefert.
func (c *Client) Run(ctx context.Context, handle func(Event) error) error {
sleep := c.sleep
if sleep == nil {
sleep = sleepCtx
}
attempt := 0
for {
connected, err := c.connect(ctx, handle)
if ctx.Err() != nil {
return ctx.Err()
}
var he *handlerError
if errors.As(err, &he) {
return he.err
}
if connected {
attempt = 0
}
d := backoff(attempt, c.minBackoff(), c.maxBackoff())
attempt++
if c.OnError != nil {
c.OnError(err, d)
}
if err := sleep(ctx, d); err != nil {
return err
}
}
}
// backoff liefert minD · 2^attempt, höchstens maxD.
func backoff(attempt int, minD, maxD time.Duration) time.Duration {
d := minD
for range attempt {
if d >= maxD/2 {
return maxD
}
d *= 2
}
return min(d, maxD)
}
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}