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:
cottongin
2026-03-25 08:20:20 -04:00
parent b353f606e5
commit a328684af0
2 changed files with 100 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
import asyncio
import json
import logging
import re
@@ -17,7 +18,7 @@ HYDRATION_PATTERN = re.compile(r"__sc_hydration\s*=\s*(\[.*?\])\s*;", re.DOTALL)
def _build_cursor(until: datetime, user_id: int) -> str:
ts = until.strftime("%Y-%m-%dT%H:%M:%S.000Z")
padded_user = str(user_id).zfill(22)
padded_user = str(user_id).zfill(20)
return f"{ts},user-track-likes,000-{padded_user}-99999999999999999999"
@@ -55,19 +56,27 @@ class SoundCloudClient:
params = dict(params or {})
params["client_id"] = client_id
for attempt in range(3):
max_attempts = 3
for attempt in range(max_attempts):
resp = await self._http.get(url, params=params)
if resp.status_code == 401:
logger.warning("Got 401 from SoundCloud API, refreshing client_id (attempt %d)", attempt + 1)
if resp.status_code == 401 or resp.status_code >= 500:
logger.warning(
"Got %d from SoundCloud API, refreshing client_id (attempt %d/%d)",
resp.status_code, attempt + 1, max_attempts,
)
self.invalidate_client_id()
if resp.status_code >= 500:
await asyncio.sleep(2 ** attempt)
client_id = await self._extract_client_id()
params["client_id"] = client_id
continue
resp.raise_for_status()
return resp
raise httpx.HTTPStatusError(
"Failed after 3 attempts (401)",
f"Failed after {max_attempts} attempts (last status: {resp.status_code})",
request=resp.request,
response=resp,
)
@@ -86,15 +95,24 @@ class SoundCloudClient:
until: datetime,
limit: int = 50,
) -> list[Track]:
cursor = _build_cursor(until, user_id)
cursor: str | None = _build_cursor(until, user_id)
collected: list[Track] = []
used_fabricated_cursor = True
while True:
params: dict = {"limit": limit}
if cursor:
params["offset"] = cursor
resp = await self._api_get(f"{API_BASE}/users/{user_id}/likes", params=params)
try:
resp = await self._api_get(f"{API_BASE}/users/{user_id}/likes", params=params)
except httpx.HTTPStatusError as exc:
if used_fabricated_cursor and cursor and exc.response.status_code >= 500:
logger.warning("Fabricated cursor rejected (HTTP %d), retrying without cursor", exc.response.status_code)
cursor = None
used_fabricated_cursor = False
continue
raise
data = resp.json()
collection = data.get("collection", [])