feat(youtube): process-wide caption-fetch rate gate (ADR-014 item 2)
ADR-014 item 2 — a single per-egress-IP rate gate shared by every caption fetch — was specced but only per-video backoff (rate_limited_at) shipped. Build the real gate now: it is load-bearing once ADR-018 puts auto-summarize on an in-process schedule across multiple users (all fetches leave one pod's egress IP, concurrently with live "Summarize" clicks — without a shared gate that self-inflicts 429s every cycle). globalFetchGate (golang.org/x/time/rate, default 2s/req burst 1) is consulted in httpDo before every live outbound fetch — player, watch-page, timedtext — so the scheduler runners and the web click-path serialise through one limiter regardless of how many users/goroutines are upstream. The test seam (a.transport != nil) skips the gate so fakes are not throttled. TAPIR_FETCH_RATE (Go duration, default 2s, 0 = unlimited) wires SetFetchRate in cmdRun; the existing per-video backoff stays as the complementary 429 handler. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
package youtube
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// waitOn drives a test-scoped limiter the same way WaitFetchGate drives the
|
||||
// global one, so these tests exercise the gate's behaviour without mutating the
|
||||
// process-wide gate (which would pollute sibling tests / the click-path).
|
||||
func waitOn(t *testing.T, lim *rate.Limiter) func(context.Context) error {
|
||||
t.Helper()
|
||||
return func(ctx context.Context) error { return lim.Wait(ctx) }
|
||||
}
|
||||
|
||||
func TestFetchGateSerialisesConcurrentCallers(t *testing.T) {
|
||||
const (
|
||||
n = 5
|
||||
interval = 10 * time.Millisecond
|
||||
)
|
||||
lim := rate.NewLimiter(rate.Every(interval), 1)
|
||||
wait := waitOn(t, lim)
|
||||
|
||||
var (
|
||||
inFlight, maxInFlight atomic.Int32
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
start := time.Now()
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
require.NoError(t, wait(context.Background()))
|
||||
cur := inFlight.Add(1)
|
||||
for {
|
||||
old := maxInFlight.Load()
|
||||
if cur <= old || maxInFlight.CompareAndSwap(old, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Hold the "critical section" briefly so overlap would be observable.
|
||||
time.Sleep(interval / 4)
|
||||
inFlight.Add(-1)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Equal(t, int32(1), maxInFlight.Load(),
|
||||
"the gate must admit at most one caller per interval — no overlap")
|
||||
require.GreaterOrEqual(t, elapsed, time.Duration(n-1)*interval,
|
||||
"N gated callers take at least (N-1)*interval wall time")
|
||||
}
|
||||
|
||||
func TestFetchGateRespectsContextCancellation(t *testing.T) {
|
||||
// A slow gate (1 token/hour, burst already spent) blocks; a cancelled ctx must
|
||||
// unblock Wait with an error rather than hang.
|
||||
lim := rate.NewLimiter(rate.Every(time.Hour), 1)
|
||||
require.True(t, lim.Allow(), "spend the single burst token")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
require.Error(t, waitOn(t, lim)(ctx), "cancelled ctx must fail Wait, not block")
|
||||
}
|
||||
|
||||
func TestSetFetchRateZeroIsUnlimited(t *testing.T) {
|
||||
// Snapshot and restore the global so this test does not pollute the process.
|
||||
prev := globalFetchGate
|
||||
t.Cleanup(func() { globalFetchGate = prev })
|
||||
|
||||
SetFetchRate(0)
|
||||
require.Equal(t, rate.Inf, globalFetchGate.Limit(), "0 interval = unlimited gate")
|
||||
|
||||
// An unlimited gate never blocks, even back-to-back.
|
||||
for i := 0; i < 100; i++ {
|
||||
require.NoError(t, WaitFetchGate(context.Background()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user