fix(audit-g5d): surface ErrorType taxonomy on FailingSourcesCard
Alembic 0032 adds Source.error_type (varchar(32), indexed).
_update_source_health stamps it alongside last_error on status='error'
and clears it on 'ok'. SourceRecord/to_dict exposes it.
FailingSourcesCard renders a colored chip next to the consecutive-
failures count, with a tooltip explaining the suggested operator
action. Color reflects intent:
- warning (yellow) — operator action needed (auth_error)
- info (blue) — backend-paced (rate_limited / timeout /
network_error / partial / tier_limited)
- error (red) — likely terminal without intervention
(not_found / access_denied / validation_failed /
unsupported_url / http_error / unknown_error)
Audit 2026-06-02: the backend computed 13 ErrorType categories but
only the free-text last_error reached the operator. Bulk-triage by
class ("all auth_error → rotate cookies", "12 rate_limited → just
wait") required opening Logs per row.
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
"""source.error_type: surface ErrorType taxonomy in FailingSourcesCard
|
||||||
|
|
||||||
|
Revision ID: 0032
|
||||||
|
Revises: 0031
|
||||||
|
Create Date: 2026-06-02
|
||||||
|
|
||||||
|
Audit 2026-06-02: the backend computes 13 ErrorType categories (auth_error,
|
||||||
|
rate_limited, not_found, access_denied, validation_failed, etc.) and
|
||||||
|
stamps each one on DownloadEvent.metadata, but the Source row only carried
|
||||||
|
the free-text last_error. Operators couldn't bulk-triage failing sources
|
||||||
|
("all auth_error → rotate cookies, all rate_limited → just wait") without
|
||||||
|
opening Logs per row.
|
||||||
|
|
||||||
|
This column receives the last error_type from _update_source_health
|
||||||
|
and gets cleared on a successful run. Nullable + indexed so the failing-
|
||||||
|
sources rollup can filter/group cheaply.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0032"
|
||||||
|
down_revision: Union[str, None] = "0031"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"source",
|
||||||
|
sa.Column("error_type", sa.String(length=32), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_source_error_type", "source", ["error_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_source_error_type", table_name="source")
|
||||||
|
op.drop_column("source", "error_type")
|
||||||
@@ -26,6 +26,11 @@ class Source(Base):
|
|||||||
|
|
||||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# alembic 0032: last ErrorType category (auth_error, rate_limited,
|
||||||
|
# not_found, ...). Lets FailingSourcesCard surface the taxonomy as
|
||||||
|
# a colored chip so operators can bulk-triage by error class. Set
|
||||||
|
# by _update_source_health alongside last_error; cleared on 'ok'.
|
||||||
|
error_type: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||||
check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
|||||||
@@ -434,9 +434,15 @@ class DownloadService:
|
|||||||
if status == "ok":
|
if status == "ok":
|
||||||
source.consecutive_failures = 0
|
source.consecutive_failures = 0
|
||||||
source.last_error = None
|
source.last_error = None
|
||||||
|
# alembic 0032 — clear the failure-class chip on success.
|
||||||
|
source.error_type = None
|
||||||
elif status == "error":
|
elif status == "error":
|
||||||
source.consecutive_failures = (source.consecutive_failures or 0) + 1
|
source.consecutive_failures = (source.consecutive_failures or 0) + 1
|
||||||
source.last_error = error_message
|
source.last_error = error_message
|
||||||
|
# alembic 0032 — stamp the failure-class so FailingSourcesCard
|
||||||
|
# can render a colored chip and operators can bulk-triage
|
||||||
|
# by error class without opening Logs per row.
|
||||||
|
source.error_type = error_type
|
||||||
if error_type == "rate_limited":
|
if error_type == "rate_limited":
|
||||||
await set_platform_cooldown(self.async_session, source.platform)
|
await set_platform_cooldown(self.async_session, source.platform)
|
||||||
elif status == "skipped":
|
elif status == "skipped":
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class SourceRecord:
|
|||||||
config_overrides: dict | None
|
config_overrides: dict | None
|
||||||
last_checked_at: str | None
|
last_checked_at: str | None
|
||||||
last_error: str | None
|
last_error: str | None
|
||||||
|
error_type: str | None
|
||||||
check_interval_override: int | None
|
check_interval_override: int | None
|
||||||
consecutive_failures: int
|
consecutive_failures: int
|
||||||
next_check_at: str | None
|
next_check_at: str | None
|
||||||
@@ -76,6 +77,7 @@ class SourceRecord:
|
|||||||
"config_overrides": self.config_overrides,
|
"config_overrides": self.config_overrides,
|
||||||
"last_checked_at": self.last_checked_at,
|
"last_checked_at": self.last_checked_at,
|
||||||
"last_error": self.last_error,
|
"last_error": self.last_error,
|
||||||
|
"error_type": self.error_type,
|
||||||
"check_interval_override": self.check_interval_override,
|
"check_interval_override": self.check_interval_override,
|
||||||
"consecutive_failures": self.consecutive_failures,
|
"consecutive_failures": self.consecutive_failures,
|
||||||
"next_check_at": self.next_check_at,
|
"next_check_at": self.next_check_at,
|
||||||
@@ -144,6 +146,7 @@ class SourceService:
|
|||||||
config_overrides=source.config_overrides,
|
config_overrides=source.config_overrides,
|
||||||
last_checked_at=source.last_checked_at.isoformat() if source.last_checked_at else None,
|
last_checked_at=source.last_checked_at.isoformat() if source.last_checked_at else None,
|
||||||
last_error=source.last_error,
|
last_error=source.last_error,
|
||||||
|
error_type=source.error_type,
|
||||||
check_interval_override=source.check_interval_override,
|
check_interval_override=source.check_interval_override,
|
||||||
consecutive_failures=source.consecutive_failures or 0,
|
consecutive_failures=source.consecutive_failures or 0,
|
||||||
next_check_at=nxt.isoformat() if nxt else None,
|
next_check_at=nxt.isoformat() if nxt else None,
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
<v-chip size="x-small" color="error" variant="flat" label class="fc-fail__count">
|
<v-chip size="x-small" color="error" variant="flat" label class="fc-fail__count">
|
||||||
{{ s.consecutive_failures }}× failed
|
{{ s.consecutive_failures }}× failed
|
||||||
</v-chip>
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
v-if="s.error_type"
|
||||||
|
size="x-small" variant="outlined" label
|
||||||
|
:color="errorTypeColor(s.error_type)"
|
||||||
|
class="fc-fail__class"
|
||||||
|
:title="errorTypeHint(s.error_type)"
|
||||||
|
>
|
||||||
|
{{ s.error_type }}
|
||||||
|
</v-chip>
|
||||||
<span class="fc-fail__err" :title="s.last_error || ''">
|
<span class="fc-fail__err" :title="s.last_error || ''">
|
||||||
{{ s.last_error || 'no error message recorded' }}
|
{{ s.last_error || 'no error message recorded' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -66,6 +75,42 @@ const open = ref(true)
|
|||||||
// Per-row loading flag so the spinner lives on the row whose Logs
|
// Per-row loading flag so the spinner lives on the row whose Logs
|
||||||
// button was clicked, not on every row.
|
// button was clicked, not on every row.
|
||||||
const logLoadingIds = ref(new Set())
|
const logLoadingIds = ref(new Set())
|
||||||
|
|
||||||
|
// Audit 2026-06-02: surface the ErrorType taxonomy as a colored chip
|
||||||
|
// next to the consecutive-failures count so operators can bulk-triage
|
||||||
|
// by error class. Color reflects "what to do next":
|
||||||
|
// warning (yellow) — auth/cookie issue: operator should rotate
|
||||||
|
// info (blue) — backend-paced (cooldown / rate limit / timeout)
|
||||||
|
// error (red) — likely terminal without operator intervention
|
||||||
|
const ERROR_TYPE_COLOR = {
|
||||||
|
auth_error: 'warning',
|
||||||
|
rate_limited: 'info',
|
||||||
|
timeout: 'info',
|
||||||
|
network_error: 'info',
|
||||||
|
not_found: 'error',
|
||||||
|
access_denied: 'error',
|
||||||
|
validation_failed: 'error',
|
||||||
|
unsupported_url: 'error',
|
||||||
|
http_error: 'error',
|
||||||
|
unknown_error: 'error',
|
||||||
|
partial: 'info',
|
||||||
|
tier_limited: 'info',
|
||||||
|
no_new_content: 'info',
|
||||||
|
}
|
||||||
|
const ERROR_TYPE_HINT = {
|
||||||
|
auth_error: 'Cookies likely expired — re-upload in Credentials.',
|
||||||
|
rate_limited: 'Platform-wide cooldown active. Will retry after it expires.',
|
||||||
|
timeout: 'Subprocess exceeded its time budget. Often retries cleanly.',
|
||||||
|
network_error: 'Transient network issue. Will retry on next tick.',
|
||||||
|
not_found: 'URL 404 — creator may have renamed or deleted.',
|
||||||
|
access_denied: 'Subscription tier may not grant this content.',
|
||||||
|
validation_failed: 'Downloaded files were quarantined by the validator.',
|
||||||
|
http_error: 'Generic HTTP error — see Logs.',
|
||||||
|
unsupported_url: 'gallery-dl does not support this URL pattern.',
|
||||||
|
unknown_error: 'Could not classify — see Logs.',
|
||||||
|
}
|
||||||
|
function errorTypeColor(t) { return ERROR_TYPE_COLOR[t] || 'error' }
|
||||||
|
function errorTypeHint(t) { return ERROR_TYPE_HINT[t] || '' }
|
||||||
async function onViewLogs(s) {
|
async function onViewLogs(s) {
|
||||||
if (logLoadingIds.value.has(s.id)) return
|
if (logLoadingIds.value.has(s.id)) return
|
||||||
logLoadingIds.value = new Set(logLoadingIds.value).add(s.id)
|
logLoadingIds.value = new Set(logLoadingIds.value).add(s.id)
|
||||||
@@ -115,6 +160,7 @@ async function onViewLogs(s) {
|
|||||||
}
|
}
|
||||||
.fc-fail__artist { font-weight: 600; white-space: nowrap; }
|
.fc-fail__artist { font-weight: 600; white-space: nowrap; }
|
||||||
.fc-fail__count { flex: 0 0 auto; }
|
.fc-fail__count { flex: 0 0 auto; }
|
||||||
|
.fc-fail__class { flex: 0 0 auto; }
|
||||||
.fc-fail__err {
|
.fc-fail__err {
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user