Skip to content

Commit 5ca24d8

Browse files
committed
fix(errors): report a definitive 404 as not_found, not server_unreachable
RomM answers a request for an entity it no longer has with HTTP 404. Every exception-type-blind service site collapsed that onto server_unreachable, so the UI claimed "RomM offline" while the server was plainly answering. Route those sites through classify_error, which already maps RommNotFoundError to NOT_FOUND. Sites whose verdict is a partial-success flag rather than a reason slug peel the 404 off with a sibling except RommNotFoundError instead: - get_version_list returns the local-only list with server_query_failed False on a 404, so the picker stops feeding the global connection store. - get_save_status keeps server_query_failed True (the flag is what stops the matrix classifying local files as ready-to-upload against an empty server list) and gains the additive server_query_reason slug, so only an explicit server_unreachable drives the offline state. - list_file_versions and rollback_to_version gain a not_found arm, distinct from server_unreachable (retryable) and version_deleted (one save missing from a ROM the server still has). classify_error gains a narrow ConnectionError/TimeoutError/socket.gaierror branch so a raw socket failure keeps the unreachable slug. Deliberately not bare OSError: that also covers local disk I/O, and reporting a failed settings write as "server unreachable" is the same lie pointing the other way. Messages that asserted reachability are replaced ("RomM server not reachable.", "Cannot inspect slot - server unreachable"); messages already true for both branches are kept, since only the routing slug was wrong. scripts/check_404_not_unreachable.py locks this in: a catch-all handler in services/ may not bind a verdict key (reason/status/recommended_action) to a hardcoded SERVER_UNREACHABLE. The rule is positional rather than "does the handler call classify_error", so it also catches a handler that consults the funnel and then discards its verdict. It finds 14 sites against the previous tree. steamgrid.py is exempt with a recorded reason: it talks to SteamGridDB, whose failures classify_error has no branch for.
1 parent 3b403ac commit 5ca24d8

27 files changed

Lines changed: 649 additions & 43 deletions

py_modules/lib/errors.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""RomM API error types for structured error handling."""
22

3+
import socket
34
from typing import Any
45

56
from lib.list_result import ErrorCode
@@ -170,8 +171,8 @@ def classify_error(exc):
170171
171172
``reason`` is a canonical :class:`lib.list_result.ErrorCode` slug
172173
(returned as its string value). Several exception types fold onto one
173-
slug \u2014 connection/timeout/SSL/5xx/generic-API all map to
174-
``server_unreachable``; 401 and 403 both map to ``auth_failed`` \u2014 but
174+
slug \u2014 connection/timeout/SSL/5xx/generic-API plus raw socket errors
175+
all map to ``server_unreachable``; 401/403 map to ``auth_failed`` \u2014 but
175176
each branch keeps a distinct human ``message``. In particular the 403
176177
branch stays distinguishable from the 401 branch: a Cloudflare
177178
bot-fight 403 at the tunnel edge is not a wrong-credentials failure, so
@@ -212,6 +213,14 @@ def classify_error(exc):
212213
)
213214
if isinstance(exc, RommApiError):
214215
return ErrorCode.SERVER_UNREACHABLE.value, str(exc)
216+
if isinstance(exc, (ConnectionError, TimeoutError, socket.gaierror)):
217+
# A socket-level failure that never passed through the adapter's
218+
# ``translate_http_error`` (a caller reaching the network outside the
219+
# RomM client). Same reachability verdict as ``RommConnectionError``.
220+
# Deliberately NOT bare ``OSError``: that also covers local disk I/O,
221+
# so a failed settings write would report the server as unreachable —
222+
# the same class of lie as reporting a 404 that way.
223+
return ErrorCode.SERVER_UNREACHABLE.value, f"Server unreachable — {exc}"
215224
return ErrorCode.UNKNOWN.value, str(exc)
216225

217226

py_modules/services/achievements.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from typing import TYPE_CHECKING, Any
1515

1616
from domain.achievements import extract_achievements_from_rom, extract_game_progress
17-
from lib.list_result import ErrorCode
17+
from lib.errors import classify_error
1818

1919
if TYPE_CHECKING:
2020
import asyncio
@@ -159,9 +159,10 @@ async def get_achievements(self, rom_id):
159159
"total": len(stale["achievements"]),
160160
"stale": True,
161161
}
162+
reason, _message = classify_error(e)
162163
return {
163164
"success": False,
164-
"reason": ErrorCode.SERVER_UNREACHABLE.value,
165+
"reason": reason,
165166
"message": str(e),
166167
"achievements": [],
167168
"total": 0,
@@ -219,9 +220,10 @@ async def get_achievement_progress(self, rom_id):
219220
stale_progress = self._achievements_cache.get(rom_id_str, {}).get("user_progress")
220221
if stale_progress:
221222
return {**self._progress_data_response(stale_progress), "stale": True}
223+
reason, _message = classify_error(e)
222224
return {
223225
"success": False,
224-
"reason": ErrorCode.SERVER_UNREACHABLE.value,
226+
"reason": reason,
225227
"message": str(e),
226228
"earned": 0,
227229
"total": 0,

py_modules/services/artwork.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
)
5757
from domain.cover_refresh import cover_ts_only_change, scan_cover_refresh_candidates
5858
from domain.sync_stage import SyncStage
59-
from lib.errors import RommNotFoundError
59+
from lib.errors import RommNotFoundError, classify_error
6060
from lib.list_result import ErrorCode
6161

6262

@@ -823,9 +823,13 @@ async def refresh_cover(self, rom_id: int) -> dict[str, Any]:
823823
rom = await self._loop.run_in_executor(None, self._romm_api.get_rom, rom_id)
824824
except Exception as e:
825825
self._logger.warning(f"refresh_cover: failed to fetch rom {rom_id}: {e}")
826+
reason, _message = classify_error(e)
826827
return {
827828
"success": False,
828-
"reason": ErrorCode.SERVER_UNREACHABLE.value,
829+
"reason": reason,
830+
# Already true for both branches (the ROM is gone / the server
831+
# is down), and more specific than the classifier's generic
832+
# string — only the routing slug was ever wrong here.
829833
"message": "Could not fetch ROM from server",
830834
}
831835
if not rom:

py_modules/services/saves/slots/deletion.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from typing import TYPE_CHECKING, Any
1414

1515
from domain.save_slot import save_in_slot, slot_query_param
16-
from lib.list_result import ErrorCode
16+
from lib.errors import classify_error
1717
from services.saves._settings import save_sync_enabled
1818

1919
if TYPE_CHECKING:
@@ -128,10 +128,11 @@ async def get_slot_delete_info(self, rom_id: int, slot: str) -> dict[str, Any]:
128128
self._logger.warning(
129129
f"get_slot_delete_info: failed to list saves for slot '{slot}': {e}",
130130
)
131+
reason, message = classify_error(e)
131132
return {
132133
"success": False,
133-
"reason": ErrorCode.SERVER_UNREACHABLE.value,
134-
"message": "Cannot inspect slot — server unreachable",
134+
"reason": reason,
135+
"message": f"Cannot inspect slot — {message}",
135136
}
136137

137138
# Local tracked files pointing to server saves in this slot
@@ -178,9 +179,10 @@ async def _delete_server_slot_saves(self, rom_id: int, slot: str) -> dict[str, A
178179
return {"success": True, "count": len(save_ids), "ids": set(save_ids)}
179180
except Exception as e:
180181
self._logger.warning(f"delete_slot: server delete failed for slot '{slot}': {e}")
182+
reason, _message = classify_error(e)
181183
return {
182184
"success": False,
183-
"reason": ErrorCode.SERVER_UNREACHABLE.value,
185+
"reason": reason,
184186
"message": f"Failed to delete server saves: {e}",
185187
}
186188

py_modules/services/saves/slots/listing.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from domain.rom_save_sync_state import RomSaveSyncState
1414
from domain.save_slot import save_in_slot, slot_query_param
15-
from lib.list_result import ErrorCode
15+
from lib.errors import classify_error
1616
from services.saves._messages import SAVE_SYNC_DISABLED
1717
from services.saves._settings import resolve_default_slot, save_sync_enabled
1818

@@ -98,9 +98,12 @@ async def get_save_slots(self, rom_id: int) -> dict[str, Any]:
9898
)
9999
except Exception as e:
100100
self._log_debug(f"Failed to fetch save slots for rom {rom_id}: {e}")
101+
reason, _message = classify_error(e)
101102
return {
102103
"success": False,
103-
"reason": ErrorCode.SERVER_UNREACHABLE,
104+
"reason": reason,
105+
# The raw exception text is neutral and carries the concrete
106+
# detail; only the routing slug was ever wrong here.
104107
"message": str(e),
105108
"slots": [],
106109
"active_slot": active_slot,
@@ -212,9 +215,10 @@ async def get_slot_saves(self, rom_id: int, slot: str) -> dict[str, Any]:
212215
]
213216
return {"success": True, "slot": slot, "saves": saves}
214217
except Exception as e:
218+
reason, _message = classify_error(e)
215219
return {
216220
"success": False,
217-
"reason": ErrorCode.SERVER_UNREACHABLE,
221+
"reason": reason,
218222
"message": str(e),
219223
"slot": slot,
220224
"saves": [],

py_modules/services/saves/slots/switching.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from domain.rom_save_sync_state import RomSaveSyncState
1616
from domain.save_layout import SAVE_SYNC_CONTENT_DIR_REASON
1717
from domain.save_slot import save_in_slot
18-
from lib.list_result import ErrorCode
18+
from lib.errors import classify_error
1919
from services.saves._helpers import newest_server_saves_by_target
2020
from services.saves._messages import SAVE_SYNC_IN_CONTENT_DIR
2121
from services.saves._settings import resolve_default_slot, save_sync_enabled
@@ -237,9 +237,10 @@ async def switch_slot(self, rom_id: int, new_slot: str) -> dict[str, Any]:
237237
),
238238
)
239239
except Exception as e:
240+
reason, _message = classify_error(e)
240241
return {
241242
"success": False,
242-
"reason": ErrorCode.SERVER_UNREACHABLE.value,
243+
"reason": reason,
243244
"message": str(e),
244245
}
245246

py_modules/services/saves/status/service.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
status_from_action,
1616
)
1717
from domain.sync_action import Conflict, Skip
18+
from lib.errors import classify_error
1819
from services.saves._settings import save_sync_enabled
1920

2021
if TYPE_CHECKING:
@@ -170,6 +171,7 @@ def _get_save_status_io(
170171
server_saves: list[dict[str, Any]],
171172
*,
172173
server_query_failed: bool = False,
174+
server_query_reason: str | None = None,
173175
) -> dict[str, Any]:
174176
"""Sync helper for get_save_status — runs in executor.
175177
@@ -309,6 +311,15 @@ def _get_save_status_io(
309311
"savefiles_in_content_dir": savefiles_in_content_dir,
310312
"save_sync_display": save_sync_display,
311313
"server_query_failed": server_query_failed,
314+
# Why the query failed, as a :func:`classify_error` slug (None when
315+
# it didn't). Additive alongside the flag, per CLAUDE.md's
316+
# partial-success carve-out — do NOT collapse this into the
317+
# canonical ``{success, reason, message}``: the response is a full
318+
# payload the UI renders, and the binary ``success`` would erase
319+
# that. The flag stays the "we lack the server's view" signal that
320+
# drives ``status="unknown"``; this slug only says why, so a
321+
# definitive 404 stops being reported as an offline server (#1570).
322+
"server_query_reason": server_query_reason,
312323
# Interim #908 guard: a slot whose current save spans >1 distinct
313324
# file (e.g. Saturn .bkr/.bcr/.smpc) is an N-file *set*, not a
314325
# single file with a version history. Per-version rollback would
@@ -337,6 +348,13 @@ async def get_save_status(self, rom_id: int) -> dict[str, Any]:
337348
the matrix-derived "ready to upload" label that an empty server
338349
list would otherwise produce — see ``_get_save_status_io``.
339350
351+
``server_query_reason`` carries the :func:`classify_error` slug for
352+
that failure (``None`` on success). The flag and the slug answer
353+
different questions: the flag says the server's view is unknown —
354+
true for a 404 as much as for an outage, and what keeps the matrix
355+
from acting on an empty list — while the slug says *why*, so only an
356+
explicit ``server_unreachable`` drives the UI's offline state (#1570).
357+
340358
The additive ``savefiles_in_content_dir: bool`` flag is ``True``
341359
when RetroArch writes saves next to the ROM (the unsupported case):
342360
local probing is skipped and the display reads "Save sync off",
@@ -353,6 +371,7 @@ async def get_save_status(self, rom_id: int) -> dict[str, Any]:
353371

354372
server_saves: list[dict[str, Any]] = []
355373
server_query_failed = False
374+
server_query_reason: str | None = None
356375
try:
357376
device_id = await self._loop.run_in_executor(None, self._device_registry.get_device_id)
358377
server_saves = await self._loop.run_in_executor(
@@ -362,6 +381,7 @@ async def get_save_status(self, rom_id: int) -> dict[str, Any]:
362381
except Exception as e:
363382
self._log_debug(f"Failed to fetch saves for rom {rom_id}: {e}")
364383
server_query_failed = True
384+
server_query_reason, _message = classify_error(e)
365385

366386
async with self._sync_engine.rom_lock(rom_id):
367387
return await self._loop.run_in_executor(
@@ -370,6 +390,7 @@ async def get_save_status(self, rom_id: int) -> dict[str, Any]:
370390
rom_id,
371391
server_saves,
372392
server_query_failed=server_query_failed,
393+
server_query_reason=server_query_reason,
373394
),
374395
)
375396

py_modules/services/saves/sync_engine/devices.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,9 +321,12 @@ async def list_devices(self, *, loop: asyncio.AbstractEventLoop) -> dict[str, An
321321
return {"success": True, "devices": enriched}
322322
except Exception as e:
323323
self._log_debug(f"list_devices failed: {e}")
324+
reason, _message = classify_error(e)
324325
return {
325326
"success": False,
326-
"reason": ErrorCode.SERVER_UNREACHABLE.value,
327+
"reason": reason,
328+
# Neutral and true whichever way the call failed — only the
329+
# routing slug was ever wrong here.
327330
"message": "Could not load devices",
328331
"devices": [],
329332
}

py_modules/services/saves/sync_engine/rollback.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,11 @@ async def resolve(
142142
),
143143
)
144144
except Exception as e:
145-
_code, _msg = classify_error(e)
145+
reason, message = classify_error(e)
146146
return {
147147
"success": False,
148-
"reason": ErrorCode.SERVER_UNREACHABLE.value,
149-
"message": f"Failed to fetch saves: {_msg}",
148+
"reason": reason,
149+
"message": f"Failed to fetch saves: {message}",
150150
}
151151

152152
active_slot = save_state.active_slot

py_modules/services/saves/versions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from domain.save_layout import SAVE_SYNC_CONTENT_DIR_REASON
2121
from domain.save_slot import save_in_slot, slot_query_param
2222
from domain.save_status import compute_multi_file_slot
23+
from lib.errors import RommNotFoundError
2324
from services.saves._helpers import local_save_target
2425
from services.saves._settings import resolve_default_slot
2526

@@ -143,6 +144,11 @@ async def list_file_versions(self, rom_id: int, slot: str, filename: str) -> dic
143144
``list_saves`` call failed (network, server, auth, etc.). The
144145
frontend distinguishes this from an empty list so it can show a
145146
retry affordance instead of "no versions available".
147+
- ``{"status": "not_found", "message": ...}`` if the ``list_saves``
148+
call drew a definitive 404 — RomM no longer has this ROM or the
149+
registered device id. Distinct from ``server_unreachable``: the
150+
server answered, so retrying is pointless and the panel says so
151+
instead of blaming the connection (#1570).
146152
"""
147153
rom_id = int(rom_id)
148154
save_state, device_id = await self._loop.run_in_executor(None, self._read_inputs, rom_id)
@@ -159,6 +165,11 @@ async def list_file_versions(self, rom_id: int, slot: str, filename: str) -> dic
159165
lambda: self._romm_api.list_saves(rom_id, device_id=device_id, slot=slot_query_param(slot))
160166
),
161167
)
168+
except RommNotFoundError as e:
169+
# The server answered: it has no such ROM (or no such device id). A
170+
# retry cannot change that, so the panel must not offer one (#1570).
171+
self._log_debug(f"list_file_versions: server has no such entity: {e}")
172+
return {"status": "not_found", "message": str(e)}
162173
except Exception as e:
163174
self._log_debug(f"list_file_versions: failed to list saves: {e}")
164175
return {"status": "server_unreachable", "message": str(e)}
@@ -318,6 +329,11 @@ async def rollback_to_version(self, rom_id: int, slot: str, save_id: int) -> dic
318329
auth, etc.). The frontend distinguishes this from
319330
``version_deleted`` so it can show a retry affordance instead
320331
of "version no longer on the server".
332+
- ``{"status": "not_found", "message": ...}`` if that same call drew
333+
a definitive 404 — RomM no longer has this ROM or the registered
334+
device id. Distinct from both siblings: ``version_deleted`` is one
335+
missing save inside a ROM the server still has, and
336+
``server_unreachable`` is a connection the user can retry (#1570).
321337
- ``{"status": "conflict_blocked", "conflicts": [...]}`` if the
322338
pre-flight surfaced a conflict on the currently-tracked save.
323339
The frontend resolves it via the standard conflict modal.
@@ -387,6 +403,13 @@ async def rollback_to_version(self, rom_id: int, slot: str, save_id: int) -> dic
387403
lambda: self._romm_api.list_saves(rom_id, device_id=device_id, slot=slot_query_param(slot))
388404
),
389405
)
406+
except RommNotFoundError as e:
407+
# The server answered: it has no such ROM (or no such device id).
408+
# Not a connectivity verdict, so the toast must not blame the
409+
# connection or invite a retry that cannot succeed (#1570).
410+
self._log_debug(f"rollback_to_version: server has no such entity: {e}")
411+
await self._loop.run_in_executor(None, self._write_save_state, rom_id, save_state)
412+
return {"status": "not_found", "message": str(e)}
390413
except Exception as e:
391414
self._log_debug(f"rollback_to_version: failed to list saves: {e}")
392415
# Persist whatever the pre-flight mutated before bailing.

0 commit comments

Comments
 (0)