fix: handle SoundCloud API 5xx errors with client_id refresh, backoff, and cursor fallback
SoundCloud began rejecting the fabricated pagination cursor with 500 errors. Fixed cursor user_id padding (zfill 22→20) to match the documented format, added 5xx retry with exponential backoff in _api_get, and added a fallback in fetch_likes that drops the fabricated cursor when it causes persistent 500s. Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ntr_fetcher.soundcloud import SoundCloudClient
|
||||
@@ -151,3 +153,76 @@ async def test_fetch_likes_retries_on_401(httpx_mock):
|
||||
until=datetime(2026, 3, 10, 0, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
assert len(tracks) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ntr_fetcher.soundcloud.asyncio.sleep", return_value=None)
|
||||
async def test_fetch_likes_retries_on_500(mock_sleep, httpx_mock):
|
||||
httpx_mock.add_response(url="https://soundcloud.com", text=FAKE_HTML)
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api-v2\.soundcloud\.com/users/206979918/likes.*"),
|
||||
status_code=500,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="https://soundcloud.com",
|
||||
text=FAKE_HTML.replace("test_client_id_abc123", "fresh_client_id_789"),
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api-v2\.soundcloud\.com/users/206979918/likes.*"),
|
||||
json=FAKE_LIKES_RESPONSE,
|
||||
)
|
||||
client = SoundCloudClient()
|
||||
tracks = await client.fetch_likes(
|
||||
user_id=206979918,
|
||||
since=datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc),
|
||||
until=datetime(2026, 3, 10, 0, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
assert len(tracks) == 1
|
||||
mock_sleep.assert_called_once_with(1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ntr_fetcher.soundcloud.asyncio.sleep", return_value=None)
|
||||
async def test_fetch_likes_falls_back_to_no_cursor_on_persistent_500(mock_sleep, httpx_mock):
|
||||
"""When the fabricated cursor causes persistent 500s, fall back to no cursor."""
|
||||
httpx_mock.add_response(url="https://soundcloud.com", text=FAKE_HTML)
|
||||
for _ in range(3):
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api-v2\.soundcloud\.com/users/206979918/likes.*"),
|
||||
status_code=500,
|
||||
)
|
||||
httpx_mock.add_response(url="https://soundcloud.com", text=FAKE_HTML)
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api-v2\.soundcloud\.com/users/206979918/likes.*"),
|
||||
json=FAKE_LIKES_RESPONSE,
|
||||
)
|
||||
client = SoundCloudClient()
|
||||
tracks = await client.fetch_likes(
|
||||
user_id=206979918,
|
||||
since=datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc),
|
||||
until=datetime(2026, 3, 10, 0, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
assert len(tracks) == 1
|
||||
assert mock_sleep.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ntr_fetcher.soundcloud.asyncio.sleep", return_value=None)
|
||||
async def test_fetch_likes_raises_when_all_requests_fail_500(mock_sleep, httpx_mock):
|
||||
"""When both fabricated cursor and cursorless fallback fail, the error propagates."""
|
||||
httpx_mock.add_response(url="https://soundcloud.com", text=FAKE_HTML)
|
||||
# 3 retries for fabricated cursor + 3 retries for cursorless fallback = 6 API calls
|
||||
for _ in range(6):
|
||||
httpx_mock.add_response(
|
||||
url=re.compile(r"https://api-v2\.soundcloud\.com/users/206979918/likes.*"),
|
||||
status_code=500,
|
||||
)
|
||||
httpx_mock.add_response(url="https://soundcloud.com", text=FAKE_HTML)
|
||||
client = SoundCloudClient()
|
||||
with pytest.raises(httpx.HTTPStatusError, match="500"):
|
||||
await client.fetch_likes(
|
||||
user_id=206979918,
|
||||
since=datetime(2026, 3, 1, 0, 0, 0, tzinfo=timezone.utc),
|
||||
until=datetime(2026, 3, 10, 0, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
assert mock_sleep.call_count == 6
|
||||
|
||||
Reference in New Issue
Block a user