feat(radio): back off long on permanent ICY errors

Dead stream URLs (4xx) and stations without icy-metaint retried
every 30s for the whole session; classify them permanent and
retry every 10 minutes instead.

Signed-off-by: hiyukoim <11803292+hiyukoim@users.noreply.github.com>
This commit is contained in:
hiyukoim 2026-07-07 12:26:13 +02:00
parent 989d15074a
commit fd3c3b2be2
3 changed files with 63 additions and 1 deletions

View File

@ -19,6 +19,29 @@ var (
// keep an ICY reader connected to the station forever.
const defaultSessionTTL = 10 * time.Minute
// permanentRetryDelay is used instead of the regular backoff when the reader
// reports a permanent error (dead URL, station without ICY metadata), so we
// do not hammer a stream that will never yield titles.
const permanentRetryDelay = 10 * time.Minute
type permanentError struct{ err error }
func (e *permanentError) Error() string { return e.err.Error() }
func (e *permanentError) Unwrap() error { return e.err }
// MarkPermanent wraps err so the retry loop switches to permanentRetryDelay.
func MarkPermanent(err error) error {
if err == nil {
return nil
}
return &permanentError{err: err}
}
func isPermanent(err error) bool {
var p *permanentError
return errors.As(err, &p)
}
type Station struct {
ID string
StreamURL string
@ -229,6 +252,9 @@ func (m *MetadataManager) runReader(reader *activeReader) {
}
delay := m.backoff(attempt)
if isPermanent(err) && delay < permanentRetryDelay {
delay = permanentRetryDelay
}
if delay <= 0 {
continue
}

View File

@ -155,6 +155,25 @@ var _ = Describe("MetadataManager", func() {
Expect(manager.Start(context.Background(), "session-1", Station{ID: "rd-1", StreamURL: ""})).To(MatchError(ErrInvalidStation))
})
It("backs off for a long time on permanent reader errors", func() {
reader.err = MarkPermanent(errors.New("missing icy metadata interval"))
slow := NewMetadataManager(
reader.Read,
publisher.Publish,
WithRetryBackoff(func(int) time.Duration { return time.Millisecond }),
)
Expect(slow.Start(context.Background(), "session-1", Station{
ID: "rd-1",
StreamURL: "https://stream.example.test/radio",
})).To(Succeed())
Eventually(reader.StartCount).Should(Equal(1))
// Regular backoff is 1ms; the permanent error must stretch it, so no
// second attempt happens within the observation window.
Consistently(reader.StartCount, 300*time.Millisecond, 20*time.Millisecond).Should(Equal(1))
})
It("expires sessions that are never refreshed", func() {
expiring := NewMetadataManager(
reader.Read,

View File

@ -2,6 +2,7 @@ package radio
import (
"context"
"errors"
"net"
"net/http"
"time"
@ -26,6 +27,22 @@ var icyHTTPClient = &http.Client{
func NewMetadataManagerService(publisher TitlePublisher) *MetadataManager {
return NewMetadataManager(func(ctx context.Context, streamURL string, handleTitle func(string)) error {
return icy.ReadHTTPStreamTitles(ctx, icyHTTPClient, streamURL, handleTitle)
return classifyICYError(icy.ReadHTTPStreamTitles(ctx, icyHTTPClient, streamURL, handleTitle))
}, publisher)
}
// classifyICYError marks errors that a retry will not fix — a stream without
// ICY metadata or a client-error HTTP status — so the reader backs off for
// much longer instead of hammering the station.
func classifyICYError(err error) error {
if errors.Is(err, icy.ErrMissingMetaInt) {
return MarkPermanent(err)
}
var statusErr *icy.ResponseStatusError
if errors.As(err, &statusErr) &&
statusErr.StatusCode >= 400 && statusErr.StatusCode < 500 &&
statusErr.StatusCode != http.StatusRequestTimeout && statusErr.StatusCode != http.StatusTooManyRequests {
return MarkPermanent(err)
}
return err
}