audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: return 404 (not 500) for playlists with missing PDS records (#1240)

root cause: brenthueth.bsky.social's artist.pds_url was null (DID resolution
failed at signup). playlist reads fell back to bsky.network relay, which
hadn't indexed the just-created record — relay returned 404, we surfaced 500.

two fixes:
- get_record_public() now raises RecordNotFound for PDS 404s, and playlist
read endpoints catch it to return 404 instead of 500
- get_record_public_resilient() now resolves DID even when pds_url is None
(previously it gave up immediately), so the retry path works for artists
with missing pds_url

closes the "activity feed links to broken playlist" report.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4.6 (1M context)
and committed by
GitHub
262eab6e 90095b02

+64 -5
+2
backend/src/backend/_internal/atproto/records/__init__.py
··· 2 2 3 3 # re-export commonly used functions for convenience 4 4 from backend._internal.atproto.records.fm_plyr import ( 5 + RecordNotFound, 5 6 build_track_record, 6 7 create_comment_record, 7 8 create_like_record, ··· 31 32 ) 32 33 33 34 __all__ = [ 35 + "RecordNotFound", 34 36 "_make_pds_request", 35 37 "_reconstruct_oauth_session", 36 38 "_refresh_session_tokens",
+2
backend/src/backend/_internal/atproto/records/fm_plyr/__init__.py
··· 14 14 ) 15 15 from backend._internal.atproto.records.fm_plyr.profile import upsert_profile_record 16 16 from backend._internal.atproto.records.fm_plyr.track import ( 17 + RecordNotFound, 17 18 build_track_record, 18 19 create_track_record, 19 20 delete_record_by_uri, ··· 23 24 ) 24 25 25 26 __all__ = [ 27 + "RecordNotFound", 26 28 "build_list_record", 27 29 "build_track_record", 28 30 "create_comment_record",
+8 -4
backend/src/backend/_internal/atproto/records/fm_plyr/track.py
··· 13 13 logger = logging.getLogger(__name__) 14 14 15 15 16 + class RecordNotFound(Exception): 17 + """raised when an ATProto record does not exist on the PDS.""" 18 + 19 + 16 20 def build_track_record( 17 21 title: str, 18 22 artist: str, ··· 201 205 async with httpx.AsyncClient() as client: 202 206 response = await client.get(url, params=params, timeout=10.0) 203 207 208 + if response.status_code == 404: 209 + raise RecordNotFound(f"record not found: {record_uri}") 204 210 if response.status_code != 200: 205 211 raise Exception( 206 212 f"failed to fetch record: {response.status_code} {response.text}" ··· 222 228 try: 223 229 return await get_record_public(record_uri, pds_url), None 224 230 except Exception as original_error: 225 - if not pds_url: 226 - raise 227 - 228 231 # resolve the DID to find the current PDS 229 232 repo, _, _ = parse_at_uri(record_uri) 230 233 try: ··· 238 241 raise original_error 239 242 240 243 logger.info( 241 - "PDS URL changed for %s: %s -> %s, retrying", 244 + "PDS URL %s for %s: %s -> %s, retrying", 245 + "resolved" if not pds_url else "changed", 242 246 repo, 243 247 pds_url, 244 248 resolved_url,
+9
backend/src/backend/api/lists.py
··· 22 22 from backend._internal import get_optional_session, require_auth 23 23 from backend._internal.atproto.client import parse_at_uri 24 24 from backend._internal.atproto.records import ( 25 + RecordNotFound, 25 26 _reconstruct_oauth_session, 26 27 create_list_record, 27 28 get_record_public_resilient, ··· 397 398 artist.pds_url = resolved_pds_url 398 399 db.add(artist) 399 400 await db.commit() 401 + except RecordNotFound: 402 + raise HTTPException( 403 + status_code=404, detail="playlist record not found on PDS" 404 + ) from None 400 405 except Exception as e: 401 406 raise HTTPException( 402 407 status_code=500, detail=f"failed to fetch playlist record: {e}" ··· 525 530 artist.pds_url = resolved_pds_url 526 531 db.add(artist) 527 532 await db.commit() 533 + except RecordNotFound: 534 + raise HTTPException( 535 + status_code=404, detail="playlist record not found on PDS" 536 + ) from None 528 537 except Exception as e: 529 538 raise HTTPException( 530 539 status_code=500, detail=f"failed to fetch playlist record: {e}"
+42
backend/tests/api/test_playlist_by_uri.py
··· 9 9 from sqlalchemy.ext.asyncio import AsyncSession 10 10 11 11 from backend._internal import Session, require_auth 12 + from backend._internal.atproto.records import RecordNotFound 12 13 from backend.main import app 13 14 from backend.models import Artist, Playlist 14 15 ··· 120 121 121 122 assert response.status_code == 404 122 123 assert response.json()["detail"] == "playlist not found" 124 + 125 + 126 + async def test_get_playlist_by_uri_pds_record_not_found( 127 + test_app: FastAPI, db_session: AsyncSession, test_playlist: Playlist 128 + ) -> None: 129 + """playlist exists in DB but PDS record is gone — returns 404 not 500.""" 130 + with patch( 131 + "backend.api.lists.get_record_public_resilient", 132 + new_callable=AsyncMock, 133 + side_effect=RecordNotFound("record not found"), 134 + ): 135 + async with AsyncClient( 136 + transport=ASGITransport(app=test_app), base_url="http://test" 137 + ) as client: 138 + response = await client.get( 139 + "/lists/playlists/by-uri", 140 + params={"uri": PLAYLIST_URI}, 141 + ) 142 + 143 + assert response.status_code == 404 144 + assert "not found on PDS" in response.json()["detail"] 145 + 146 + 147 + async def test_get_playlist_by_id_pds_record_not_found( 148 + test_app: FastAPI, db_session: AsyncSession, test_playlist: Playlist 149 + ) -> None: 150 + """playlist exists in DB but PDS record is gone — returns 404 not 500.""" 151 + with patch( 152 + "backend.api.lists.get_record_public_resilient", 153 + new_callable=AsyncMock, 154 + side_effect=RecordNotFound("record not found"), 155 + ): 156 + async with AsyncClient( 157 + transport=ASGITransport(app=test_app), base_url="http://test" 158 + ) as client: 159 + response = await client.get( 160 + f"/lists/playlists/{test_playlist.id}", 161 + ) 162 + 163 + assert response.status_code == 404 164 + assert "not found on PDS" in response.json()["detail"]
+1 -1
loq.toml
··· 35 35 36 36 [[rules]] 37 37 path = "backend/src/backend/api/lists.py" 38 - max_lines = 1137 38 + max_lines = 1146 39 39 40 40 [[rules]] 41 41 path = "backend/src/backend/api/tracks/listing.py"