From 4678d473b83c1cd6fafb2b9ede2270f008c7cd7a Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 22:44:47 +0200 Subject: [PATCH] feat(youtube): map caption 429 to SourceRateLimited The baseUrl fetch mapped every non-200 to SourceNone, recording a 429 as a permanent "no captions". 429 is the IP being rate-limited, not an absent transcript. Return SourceRateLimited (still a graceful degrade, no error) so the runner can retry after a backoff window. Other non-200s (403/404/5xx) stay SourceNone. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/youtube/captions.go | 6 +++++ internal/adapters/youtube/youtube_test.go | 31 +++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/internal/adapters/youtube/captions.go b/internal/adapters/youtube/captions.go index de87d88..975a934 100644 --- a/internal/adapters/youtube/captions.go +++ b/internal/adapters/youtube/captions.go @@ -60,6 +60,12 @@ func (a *Adapter) FetchTranscript(ctx context.Context, v domain.Video) (domain.T if err != nil { return domain.Transcript{}, fmt.Errorf("download caption track for %q: %w", v.ProviderVideoID, err) } + if status == http.StatusTooManyRequests { + // 429 means the IP is rate-limited; record for retry, not a permanent + // absence. Degrade gracefully (no error, no text) like SourceNone, but + // flag it distinctly so the runner backs off and retries (ADR-007/010). + return domain.Transcript{VideoID: v.ID, UserID: v.UserID, Source: domain.SourceRateLimited}, nil + } if status != http.StatusOK { // Owner-only 403, region/age gate, or transient unavailability: not an error. return noTranscript(v), nil diff --git a/internal/adapters/youtube/youtube_test.go b/internal/adapters/youtube/youtube_test.go index f0feae4..133de16 100644 --- a/internal/adapters/youtube/youtube_test.go +++ b/internal/adapters/youtube/youtube_test.go @@ -388,6 +388,37 @@ func TestFetchTranscriptBaseURLForbiddenDegrades(t *testing.T) { } } +// A 429 on the baseUrl fetch is the IP being rate-limited, NOT a permanent +// absence of captions: it returns SourceRateLimited (no error, no text) so the +// runner can record it and retry after a backoff window rather than recording a +// false "no transcript". +func TestFetchTranscriptRateLimitedReturnsSourceRateLimited(t *testing.T) { + a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/youtubei/v1/player": + base := "http://" + r.Host + _, _ = w.Write([]byte(`{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[` + + `{"baseUrl":"` + base + `/api/timedtext?lang=en","languageCode":"en"}]}}}`)) + case "/api/timedtext": + w.WriteHeader(http.StatusTooManyRequests) + } + }) + + tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"}) + if err != nil { + t.Fatalf("429 on baseUrl must degrade, not error: %v", err) + } + if tr.Source != domain.SourceRateLimited { + t.Fatalf("expected SourceRateLimited on 429, got %q", tr.Source) + } + if tr.HasText() { + t.Error("expected HasText() false for SourceRateLimited") + } + if tr.Content != "" { + t.Errorf("expected empty content on 429, got %q", tr.Content) + } +} + // An empty baseUrl on the selected track degrades to SourceNone, never an error. func TestFetchTranscriptEmptyBaseURLDegrades(t *testing.T) { a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {