fix(scheduler,i18n): a failure a job names reads in the reader's language

The audit log showed a failed RSS poll as the server's English sentence
under a Hungarian UI. A job can now raise progress.JobFailed(reason,
message, **params): the scheduler stores the reason and params as the
run's data (dashboard, manual-run notice, audit row) beside the English
line, and the client renders scheduler.failure.<reason>. The RSS outage
names its first channel and the reason code (HTTP 404 vs unreachable).
An upstream's own error text stays as raised (diagnostic).
This commit is contained in:
2026-10-01 22:51:37 +02:00
parent ec22eefd31
commit ab146211fa
10 changed files with 160 additions and 17 deletions
+21 -6
View File
@@ -83,6 +83,8 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# outage from one permanently dead feed id — which is the whole difference on an instance with
# a single subscription, where BOTH render as 100% failed.
first_error: str | None = None
# The same first failure as data, for the reader's language: whose feed, and why (`reasons`' key).
first_failure: tuple[str, str] | None = None
# What failed, as counts per reason (`http_404`, `transport`, `apply`, …) plus a few examples —
# the ONE warning a poll writes. One line per failed channel was what turned YouTube's RSS outage
# of 2026-09-16 into 11k warnings in four hours; each channel's own line is DEBUG now.
@@ -92,18 +94,19 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# hand the same rows to the same broken write) — the feed fallback's candidates.
unread: list[str] = []
def fail(reason: str, detail: str, *, unexpected: str | None = None) -> None:
def fail(reason: str, channel_id: str, detail: str, *, unexpected: str | None = None) -> None:
"""Count a channel that did not get polled. `unexpected` is the message for a failure that
is a bug on our side: its FIRST occurrence per reason gets a traceback at ERROR, the rest
DEBUG — a read-only database fails every channel the same way, and 979 identical tracebacks
tell nobody anything the first one didn't. Call it from inside the `except` block."""
nonlocal failed, first_error
nonlocal failed, first_error, first_failure
if unexpected is not None:
level = logging.ERROR if reasons[reason] == 0 else logging.DEBUG
log.log(level, unexpected, exc_info=True)
failed += 1
reasons[reason] += 1
first_error = first_error or detail
first_failure = first_failure or (channel_id, reason)
if len(examples) < RSS_FAILURE_EXAMPLES:
examples.append(detail)
@@ -125,13 +128,16 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# bug on our side and earns a traceback (the first of its kind per poll).
except RssError as exc:
log.debug("RSS poll skipped for channel %s: %s", channel.id, exc)
fail(exc.reason, f"{channel.id}: {exc}")
fail(exc.reason, channel.id, f"{channel.id}: {exc}")
unread.append(channel.id)
# silent-ok: `fail(unexpected=…)` is the report — it logs at ERROR with the
# traceback, once per reason per poll, which is the flood control this loop needs.
except Exception as exc: # noqa: BLE001 — one channel must not stop the poll
fail(
"error", f"{channel.id}: {exc}", unexpected=f"RSS fetch failed for {channel.id}"
"error",
channel.id,
f"{channel.id}: {exc}",
unexpected=f"RSS fetch failed for {channel.id}",
)
else:
try:
@@ -144,6 +150,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
db.rollback()
fail(
"apply",
channel.id,
f"{channel.id} (apply): {exc}",
unexpected=f"RSS apply failed for {channel.id}",
)
@@ -180,9 +187,17 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# "fix this subscription"; `Connection refused` says "the egress is down".
# The fallback's count rides along: a red card during an RSS outage should still say
# whether the uploads are arriving another way.
raise RuntimeError(
# As data too (E31): the audit log and the inbox say it in the reader's language, the first
# failure as its channel and its reason code (`http_404` vs `transport` is that difference).
first_channel, first_reason = first_failure or ("", "error")
raise progress.JobFailed(
"rss_all_failed",
f"RSS poll failed for all {total} channels — first: {first_error}"
f" (read through the API instead: {fallback['read']})"
f" (read through the API instead: {fallback['read']})",
total=total,
channel=first_channel,
cause=first_reason,
read=fallback["read"],
)
return {"new": new + fallback["new"], "failed": failed, "fallback": fallback["read"]}