From 8f03e315b8b84c1cf62891a73b488009690e0ce9 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Thu, 18 Jun 2026 21:10:56 +1200 Subject: [PATCH] Fix request detail load failures --- backend/app/routers/requests.py | 2 +- backend/tests/test_backend_quality.py | 17 ++++++++ frontend/app/requests/[id]/page.tsx | 63 ++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index bc0a8b0..75f6dad 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -118,6 +118,7 @@ def _cache_get(key: str) -> Optional[Dict[str, Any]]: def _cache_set(key: str, payload: Dict[str, Any]) -> None: _detail_cache[key] = (time.time() + CACHE_TTL_SECONDS, payload) + _failed_detail_cache.pop(key, None) def _status_label_with_jellyfin(current_status: Any, jellyfin_available: bool) -> str: @@ -169,7 +170,6 @@ async def _request_is_available_in_jellyfin( return True availability_cache[cache_key] = False return False - _failed_detail_cache.pop(key, None) def _failure_cache_has(key: str) -> bool: diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index a8bfddd..ea7c226 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -11,6 +11,7 @@ from backend.app.config import settings from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url from backend.app.routers import auth as auth_router from backend.app.routers import portal as portal_router +from backend.app.routers import requests as requests_router from backend.app.routers import status as status_router from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy from backend.app.services import password_reset @@ -117,6 +118,22 @@ class ServiceStatusTests(unittest.IsolatedAsyncioTestCase): self.assertIn("credentials", result["message"].lower()) +class RequestCacheTests(unittest.TestCase): + def tearDown(self) -> None: + requests_router._detail_cache.clear() + requests_router._failed_detail_cache.clear() + + def test_successful_detail_cache_write_clears_prior_failure(self) -> None: + key = "request:123" + requests_router._failure_cache_set(key) + self.assertTrue(requests_router._failure_cache_has(key)) + + requests_router._cache_set(key, {"id": 123}) + + self.assertFalse(requests_router._failure_cache_has(key)) + self.assertEqual(requests_router._cache_get(key), {"id": 123}) + + class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase): def test_set_user_email_is_case_insensitive(self) -> None: created = db.create_user_if_missing( diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index b19c792..cfa41d6 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -55,6 +55,44 @@ type ActionHistory = { created_at: string } +const readApiError = async (response: Response, fallback: string) => { + try { + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) { + const payload = await response.json() + if (typeof payload?.detail === 'string' && payload.detail.trim()) { + return payload.detail + } + if (typeof payload?.message === 'string' && payload.message.trim()) { + return payload.message + } + } else { + const text = await response.text() + if (text.trim()) { + return text.trim() + } + } + } catch (error) { + console.error(error) + } + return fallback +} + +const isSnapshotPayload = (value: unknown): value is Snapshot => { + if (!value || typeof value !== 'object') { + return false + } + const snapshot = value as Partial + return ( + typeof snapshot.request_id === 'string' && + typeof snapshot.title === 'string' && + typeof snapshot.request_type === 'string' && + typeof snapshot.state === 'string' && + Array.isArray(snapshot.timeline) && + Array.isArray(snapshot.actions) + ) +} + const percentFromTorrent = (torrent: Record) => { const progress = Number(torrent.progress) if (!Number.isNaN(progress) && progress >= 0 && progress <= 1) { @@ -201,6 +239,7 @@ export default function RequestTimelinePage() { const router = useRouter() const [snapshot, setSnapshot] = useState(null) const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) const [showDetails, setShowDetails] = useState(false) const [actionMessage, setActionMessage] = useState(null) const [releaseOptions, setReleaseOptions] = useState([]) @@ -214,6 +253,9 @@ export default function RequestTimelinePage() { return } const load = async () => { + setLoading(true) + setLoadError(null) + setSnapshot(null) try { if (!getToken()) { router.push('/login') @@ -226,12 +268,22 @@ export default function RequestTimelinePage() { authFetch(`${baseUrl}/requests/${requestId}/actions?limit=5`), ]) - if (snapshotResponse.status === 401) { + const authExpired = [snapshotResponse, historyResponse, actionsResponse].some( + (response) => response.status === 401 + ) + if (authExpired) { clearToken() router.push('/login') return } + if (!snapshotResponse.ok) { + const message = await readApiError(snapshotResponse, 'Unable to load this request.') + throw new Error(message) + } const snapshotData = await snapshotResponse.json() + if (!isSnapshotPayload(snapshotData)) { + throw new Error('Unable to load this request.') + } setSnapshot(snapshotData) setReleaseOptions([]) setSearchRan(false) @@ -251,6 +303,9 @@ export default function RequestTimelinePage() { } } catch (error) { console.error(error) + setLoadError( + error instanceof Error && error.message ? error.message : 'Unable to load this request.' + ) } finally { setLoading(false) } @@ -328,8 +383,12 @@ export default function RequestTimelinePage() { ) } + if (loadError) { + return
{loadError}
+ } + if (!snapshot) { - return
Could not load that request.
+ return
Unable to load this request.
} const summary =