247 lines
8.7 KiB
Python
247 lines
8.7 KiB
Python
"""Unit tests for the politeness primitives.
|
|
|
|
Nothing here sleeps for real: `Pacer` takes an injectable clock and sleeper, and
|
|
`backoff_delay` returns the delay instead of consuming it. A test suite that
|
|
actually waited would be the first thing anyone deleted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from yt_scraper.ratelimit import (
|
|
Pacer,
|
|
ThrottleGuard,
|
|
backoff_delay,
|
|
is_quota_exhausted,
|
|
is_rate_limited,
|
|
ydl_throttle_opts,
|
|
)
|
|
|
|
# Verbatim from the production database, where 343 of 350 `error` rows carried
|
|
# one of these. If the detector stops matching them the circuit breaker becomes
|
|
# decorative, so they are pinned here rather than paraphrased.
|
|
REAL_THROTTLE_MESSAGES = [
|
|
"ERROR: [youtube] abcdefghijk: Video unavailable. This content isn't available, "
|
|
"try again later. The current session has been rate-limited by YouTube for up to an hour.",
|
|
"ERROR: [youtube] abcdefghijk: This content isn't available, try again later. "
|
|
"The current session has been rate-limited by YouTube for up to an hour. It is recommended t",
|
|
"HTTPError: 429 Client Error: Too Many Requests for url: https://www.youtube.com/api/timedtext",
|
|
"ERROR: [youtube] xyz: Sign in to confirm you're not a bot",
|
|
"HTTP Error 429: Too Many Requests",
|
|
]
|
|
|
|
# Equally verbatim: these are permanent, must NOT trip the breaker, and must
|
|
# stay distinguishable from throttling.
|
|
REAL_PERMANENT_MESSAGES = [
|
|
"ERROR: [youtube] abcdefghijk: Join this channel to get access to members-only "
|
|
"content like this video, and other exclusive perks.",
|
|
"ERROR: [youtube] abcdefghijk: Private video. Sign in if you've been granted access to this video",
|
|
"ERROR: [youtube] abcdefghijk: This video has been removed by the uploader",
|
|
"no caption tracks published for this video",
|
|
"subtitle downloaded but parsed empty (lang=es, format=json3)",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("msg", REAL_THROTTLE_MESSAGES)
|
|
def test_detects_real_throttle_messages(msg):
|
|
assert is_rate_limited(msg) is True
|
|
|
|
|
|
@pytest.mark.parametrize("msg", REAL_PERMANENT_MESSAGES)
|
|
def test_ignores_permanent_failures(msg):
|
|
assert is_rate_limited(msg) is False
|
|
|
|
|
|
def test_quota_is_not_treated_as_plain_throttling():
|
|
"""Google documents quota exhaustion as daily; backing off cannot fix it."""
|
|
assert is_quota_exhausted("403 quotaExceeded") is True
|
|
assert is_quota_exhausted("dailyLimitExceeded") is True
|
|
assert is_quota_exhausted("rate-limited by YouTube") is False
|
|
|
|
|
|
def test_rate_limited_accepts_exception_objects():
|
|
assert is_rate_limited(RuntimeError("HTTP Error 429: Too Many Requests")) is True
|
|
|
|
|
|
# Both spellings occur, and they are different strings. yt-dlp raises
|
|
# "HTTP Error 429: ..."; `requests` raises "429 Client Error: ... for url: ...",
|
|
# which the project wraps as "HTTPError: 429 ...". Detection used to rely on the
|
|
# prose for the second form, so a 429 with no reason phrase — routine over
|
|
# HTTP/2 — went unnoticed and the breaker never counted it.
|
|
@pytest.mark.parametrize(
|
|
"msg",
|
|
[
|
|
"HTTP Error 429: Too Many Requests",
|
|
"HTTPError: 429 Client Error: Too Many Requests for url: https://youtube.com/api/timedtext",
|
|
"HTTP Error 429: HTTPError: 429 Client Error: for url: https://youtube.com/api/timedtext",
|
|
"429 Client Error: for url: https://www.youtube.com/api/timedtext?v=x",
|
|
"HTTP Error 408: Request Timeout",
|
|
"HTTPError: 408 Client Error: Request Timeout for url: https://youtube.com/",
|
|
],
|
|
)
|
|
def test_detects_the_status_code_without_relying_on_the_reason_phrase(msg):
|
|
assert is_rate_limited(msg) is True, f"undetected throttle: {msg}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"msg",
|
|
[
|
|
"HTTP Error 404: Not Found",
|
|
"HTTPError: 403 Client Error: Forbidden for url: https://youtube.com/",
|
|
"HTTP Error 500: Internal Server Error",
|
|
"no caption tracks published for this video",
|
|
# A bare number must not be read as a status code.
|
|
"video 429 seconds long with 408 segments",
|
|
],
|
|
)
|
|
def test_does_not_treat_other_statuses_as_throttling(msg):
|
|
assert is_rate_limited(msg) is False, f"false positive: {msg}"
|
|
|
|
|
|
# ---------------------------------------------------------------- backoff
|
|
|
|
|
|
def test_backoff_grows_and_is_capped():
|
|
delays = [backoff_delay(n, base=2.0, cap=60.0) for n in range(8)]
|
|
# Jitter is < 1s so successive doublings still order strictly until the cap.
|
|
assert delays[0] < delays[1] < delays[2] < delays[3]
|
|
assert all(d <= 60.0 for d in delays)
|
|
assert delays[-1] == 60.0
|
|
|
|
|
|
def test_backoff_jitters():
|
|
"""Same attempt must not produce the same delay twice, or concurrent
|
|
clients would re-synchronise into waves — the reason Google mandates it."""
|
|
seen = {backoff_delay(2, base=2.0, cap=60.0) for _ in range(30)}
|
|
assert len(seen) > 1
|
|
|
|
|
|
def test_backoff_survives_a_runaway_counter():
|
|
assert backoff_delay(10_000, base=2.0, cap=60.0) == 60.0
|
|
assert backoff_delay(-5, base=2.0, cap=60.0) <= 3.0
|
|
|
|
|
|
# ---------------------------------------------------------------- pacer
|
|
|
|
|
|
class FakeClock:
|
|
def __init__(self):
|
|
self.now = 1000.0
|
|
self.slept: list[float] = []
|
|
|
|
def time(self) -> float:
|
|
return self.now
|
|
|
|
def sleep(self, seconds: float) -> None:
|
|
self.slept.append(seconds)
|
|
self.now += seconds
|
|
|
|
|
|
def test_pacer_spaces_calls():
|
|
clock = FakeClock()
|
|
pacer = Pacer(2.0, clock=clock.time, sleeper=clock.sleep)
|
|
assert pacer.wait() == 0.0 # first call is free
|
|
assert pacer.wait() == pytest.approx(2.0)
|
|
assert pacer.wait() == pytest.approx(2.0)
|
|
assert clock.slept == [2.0, 2.0]
|
|
|
|
|
|
def test_pacer_does_not_charge_for_time_already_spent():
|
|
"""A caller slower than the interval should never wait on top of its own work."""
|
|
clock = FakeClock()
|
|
pacer = Pacer(2.0, clock=clock.time, sleeper=clock.sleep)
|
|
pacer.wait()
|
|
clock.now += 10.0 # the request itself took 10s
|
|
assert pacer.wait() == 0.0
|
|
assert clock.slept == []
|
|
|
|
|
|
def test_pacer_disabled_by_default_interval():
|
|
clock = FakeClock()
|
|
pacer = Pacer(0.0, clock=clock.time, sleeper=clock.sleep)
|
|
assert [pacer.wait() for _ in range(5)] == [0.0] * 5
|
|
assert clock.slept == []
|
|
|
|
|
|
def test_pacer_charges_for_multi_request_callers():
|
|
"""One extract_info is two HTTP requests; billing it as one halves the budget."""
|
|
clock = FakeClock()
|
|
pacer = Pacer(2.0, clock=clock.time, sleeper=clock.sleep)
|
|
pacer.wait(cost=2) # first call still free...
|
|
assert pacer.wait() == pytest.approx(4.0) # ...but it reserved two slots
|
|
|
|
|
|
def test_penalise_pushes_the_next_slot_out():
|
|
clock = FakeClock()
|
|
pacer = Pacer(1.0, clock=clock.time, sleeper=clock.sleep)
|
|
pacer.wait()
|
|
pacer.penalise(30.0)
|
|
assert pacer.wait() == pytest.approx(30.0)
|
|
|
|
|
|
# ---------------------------------------------------------------- guard
|
|
|
|
|
|
def _guard() -> ThrottleGuard:
|
|
return ThrottleGuard(threshold=3, base=0.01, cap=0.05)
|
|
|
|
|
|
def test_guard_trips_after_consecutive_throttling():
|
|
g = _guard()
|
|
assert g.note_failure(REAL_THROTTLE_MESSAGES[0]) > 0
|
|
assert not g.tripped
|
|
g.note_failure(REAL_THROTTLE_MESSAGES[0])
|
|
assert not g.tripped
|
|
g.note_failure(REAL_THROTTLE_MESSAGES[0])
|
|
assert g.tripped
|
|
assert "consecutive" in (g.tripped_reason or "")
|
|
|
|
|
|
def test_success_resets_the_streak():
|
|
"""Isolated throttled videos between successes are noise, not a banned session."""
|
|
g = _guard()
|
|
for _ in range(10):
|
|
g.note_failure(REAL_THROTTLE_MESSAGES[0])
|
|
g.note_success()
|
|
assert not g.tripped
|
|
assert g.throttled_total == 10
|
|
|
|
|
|
def test_permanent_failures_never_trip_the_breaker():
|
|
"""A channel with a few members-only videos must not look like a ban."""
|
|
g = _guard()
|
|
for msg in REAL_PERMANENT_MESSAGES * 5:
|
|
g.note_failure(msg)
|
|
assert not g.tripped
|
|
assert g.throttled_total == 0
|
|
|
|
|
|
def test_mixed_failures_do_not_accumulate_into_a_trip():
|
|
g = _guard()
|
|
g.note_failure(REAL_THROTTLE_MESSAGES[0])
|
|
g.note_failure("Private video")
|
|
g.note_failure(REAL_THROTTLE_MESSAGES[0])
|
|
g.note_failure("no caption tracks published for this video")
|
|
g.note_failure(REAL_THROTTLE_MESSAGES[0])
|
|
assert not g.tripped
|
|
|
|
|
|
def test_quota_trips_immediately_without_backoff():
|
|
g = _guard()
|
|
assert g.note_failure("403 quotaExceeded") == 0.0
|
|
assert g.tripped
|
|
assert "quota" in (g.tripped_reason or "").lower()
|
|
|
|
|
|
# ---------------------------------------------------------------- ydl opts
|
|
|
|
|
|
def test_throttle_opts_use_names_yt_dlp_actually_reads():
|
|
opts = ydl_throttle_opts(2.5, extractor_retries=4, socket_timeout=15.0)
|
|
assert opts["sleep_interval_requests"] == 2.5
|
|
assert opts["extractor_retries"] == 4
|
|
assert opts["socket_timeout"] == 15.0
|
|
# The bug this whole module exists to prevent.
|
|
assert "sleep_subrequests" not in opts
|