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) // OnConnect wird nach jedem erfolgreichen Verbindungsaufbau aufgerufen, // bevor Events gelesen werden – z. B. um den eigenen Zustand neu zu senden. OnConnect func() 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")) } if c.OnConnect != nil { c.OnConnect() } 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 } }