From fd3c3b2be2a431b4fd6c4b12e32622b384424e05 Mon Sep 17 00:00:00 2001 From: hiyukoim <11803292+hiyukoim@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:26:13 +0200 Subject: [PATCH] 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> --- core/radio/metadata_manager.go | 26 ++++++++++++++++++++++++++ core/radio/metadata_manager_test.go | 19 +++++++++++++++++++ core/radio/provider.go | 19 ++++++++++++++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/core/radio/metadata_manager.go b/core/radio/metadata_manager.go index e4e6842e9..4b4ed9613 100644 --- a/core/radio/metadata_manager.go +++ b/core/radio/metadata_manager.go @@ -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 } diff --git a/core/radio/metadata_manager_test.go b/core/radio/metadata_manager_test.go index da217853d..b03bf1f44 100644 --- a/core/radio/metadata_manager_test.go +++ b/core/radio/metadata_manager_test.go @@ -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, diff --git a/core/radio/provider.go b/core/radio/provider.go index b440545c2..f67e535fe 100644 --- a/core/radio/provider.go +++ b/core/radio/provider.go @@ -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 +}