package youtube import ( "context" "sync/atomic" "time" "golang.org/x/time/rate" ) // globalFetchGate is the process-wide rate limiter for outbound timedtext/caption // fetches. A single instance is shared by ALL Adapter instances (the scheduler's // per-user runners + the web click-path) so they cannot collectively exceed the // per-egress-IP cap. ADR-014 item 2: the gate serialises/limits concurrent caption // fetches regardless of how many users or goroutines are upstream. The 429 is per // IP, not per user — so the gate is process-wide, not per-withUser, not per-video. // // Default 2s/req (burst 1): the first fetch passes immediately, subsequent fetches // are spaced at least 2s apart. Production overrides via SetFetchRate from config. var globalFetchGate = rate.NewLimiter(rate.Every(2*time.Second), 1) // SetFetchRate replaces the process-wide gate's rate with one token per interval. // Call once at startup from config (TAPIR_FETCH_RATE). A non-positive interval // installs an unlimited gate (rate.Inf) — used in dev/tests so nothing throttles. func SetFetchRate(interval time.Duration) { if interval <= 0 { globalFetchGate = rate.NewLimiter(rate.Inf, 1) return } globalFetchGate = rate.NewLimiter(rate.Every(interval), 1) } // foregroundPending counts in-flight foreground (user-initiated) caption fetches. // The background sweep yields the gate while this is non-zero so a human waiting // on a click gets the next slot — and, on a near-throttled IP, the pre-429 window // — instead of competing equally with the firehose (ADR-026, Pillar A). Clicks are // rare and bursty, so background barely notices; the win to the click is large. var foregroundPending atomic.Int64 // fgCtxKey marks a context as foreground (user-initiated). Unexported; set via // ForegroundContext and read via isForeground so only this package owns the key. type fgCtxKey struct{} // ForegroundContext marks ctx as a user-initiated (foreground) fetch so the gate // gives it priority. The web "Summarize"/paste/retry path wraps its context with // this; the background scheduler leaves it unset. func ForegroundContext(ctx context.Context) context.Context { return context.WithValue(ctx, fgCtxKey{}, true) } func isForeground(ctx context.Context) bool { v, _ := ctx.Value(fgCtxKey{}).(bool) return v } // fgYieldPoll is how often a background waiter re-checks whether a foreground // fetch is still pending. Short enough to feel immediate, long enough not to spin. const fgYieldPoll = 200 * time.Millisecond // WaitFetchGate blocks until the process-wide gate allows one timedtext fetch, // respecting ctx cancellation. Called from httpDo before every live outbound // caption fetch so the scheduler and the click-path share the same egress budget. // // Foreground (user-initiated) fetches take priority: they register as pending and // acquire a token immediately. Background fetches first yield — they wait until no // foreground fetch is pending — so a live click is never stuck behind the // background sweep and gets the cleaner slot against the per-IP limit (ADR-026). func WaitFetchGate(ctx context.Context) error { if isForeground(ctx) { foregroundPending.Add(1) defer foregroundPending.Add(-1) return globalFetchGate.Wait(ctx) } // Background: defer to any pending foreground fetch before taking a token. for foregroundPending.Load() > 0 { select { case <-ctx.Done(): return ctx.Err() case <-time.After(fgYieldPoll): } } return globalFetchGate.Wait(ctx) }