Skip to content

feat: uniform classified-error UX across file-facing surfaces#30

Merged
narugo1992 merged 5 commits intodev/narugo1992from
feat/uniform-error-ux
Apr 24, 2026
Merged

feat: uniform classified-error UX across file-facing surfaces#30
narugo1992 merged 5 commits intodev/narugo1992from
feat/uniform-error-ux

Conversation

@narugo1992
Copy link
Copy Markdown

Summary

Extends the aggregated-fallback-error contract landed in #29 beyond the preview dialog — every file-facing surface in the SPA (blob page, edit page, RepoViewer README + tree, blob download button) now classifies errors through one shared helper and renders one shared <ErrorState> panel. No more "File Not Found" full-page for a gated repo; no more raw JSON dump in a new tab when the Download button hits an aggregated upstream failure.

Also closes the one remaining backend gap from #29: try_fallback_tree / try_fallback_info / try_fallback_paths_info now aggregate upstream failures the same way try_fallback_resolve does.

Targets dev/narugo1992 as requested.

Tracking issue: GitHub's secondary rate limit ("403 Blocked") refused to accept new issues via the API during this session. The full issue body (≈ 10 KB, same content as this PR's ## Motivation + ## Non-goals sections below) is ready at /tmp/issue-uniform-errors.md in my workspace and will be opened as a standalone issue once the block clears — I'll link it back into this PR as soon as it lands.

Motivation

#28 + #29 established that errors should be classified, not collapsed. But only FilePreviewDialog.vue actually read the X-Error-Code / sources[] contract. Every other file-facing surface threw it away and fell back to a generic "File Not Found" page or a silent empty state.

Concrete repro against animetimm/mobilenetv3_large_150d.dbv4-full (the same gated repo that drove #29):

User action Before After
Clicks the safetensors preview icon ✅ gated-specific modal ✅ (unchanged — internally now uses shared ErrorState)
Clicks the file name in the tree "File Not Found" full-page "Authentication required — attach a Hugging Face token in Settings" full-page
Clicks Download on the blob page Browser opens new tab, renders the aggregated JSON body as text ElMessage.error("This repository is gated. Attach a Hugging Face token in Settings → Tokens, then retry.") toast, no new tab
Edit page on a file whose fetch fails Generic "Go Back" full-page Classified ErrorState + Commit dialog refuses to open (prevents overwriting a gated file with empty content)
README fetch fails inside RepoViewer Silent — "No README.md found" (misleading) <ErrorState mode="inline-panel"> panel in the card tab
Root tree fetch fails Silent console.log + empty file list <ErrorState mode="inline-panel"> in the file-list slot

What landed

Shared plumbing (new files)

  • src/kohaku-hub-ui/src/utils/http-errors.jsclassifyResponse(response) for fetch Response; classifyError(err) for axios/fetch/Safetensors/plain Error; per-kind default copy via defaultCopyFor(kind). Returns {kind, status, errorCode, detail, sources}.
  • src/kohaku-hub-ui/src/components/common/ErrorState.vue — shared panel with full-page / inline-panel modes, per-kind icon + title + hint, sources-disclosure table, retry + slot-based actions.
  • Axios response interceptor in utils/api.js attaches err.classification so catch blocks don't re-parse.

Re-wired consumers (existing files)

  • FilePreviewDialog.vue — swaps its inline error template for <ErrorState> (300 LOC removed). Preview-scoped title override keeps "File header not found" wording.
  • blob/[branch]/[...file].vue — classified ErrorState; downloadFile() does a tiny GET Range: bytes=0-0 probe before window.open, surfaces a toast on failure. A 404 without X-Error-Code still retries (commit might still be processing); a 404 with a specific code short-circuits.
  • edit/[branch]/[...file].vue — classified ErrorState; Commit dialog gated on successful load.
  • RepoViewer.vue — README + tree fetches classify on failure and render inline ErrorState instead of the old silent empty states.

Backend: extend aggregate contract to the remaining three ops

try_fallback_tree / _info / _paths_info all follow the same pattern try_fallback_resolve introduced in #29 — record every non-2xx attempt, keep iterating, aggregate at the end. build_aggregate_failure_response(attempts, scope="repo" | "file") picks the right HF-compatible X-Error-Code on all-404 (info/tree → RepoNotFoundRepositoryNotFoundError; paths-info → EntryNotFoundEntryNotFoundError).

Tests

Frontend: 226 → 263 passing (+37 new, 0 regressions).

  • test_http_errors.test.js — 19 cases covering the full status × error-code × body matrix.
  • test_error_state.test.js — 18 cases (per-kind copy, overrides, detail row, Retry wiring, sources disclosure, custom actions slot, mode sizing, icon classes).
  • Existing test_file_preview_dialog.test.js updated to the new shared copy (e.g. "Request failed" / "attach a Hugging Face token"); all 18 original cases stay green.

Backend: 91 → 94 fallback tests + existing test_files.py stays green.

  • test_operations.py — the cached_and_failure_paths test updated to assert the new aggregated shapes (repo-level code for info/tree, entry-level for paths-info).
  • 3 new pattern-E tests in test_hf_hub_interop.py drive the aggregated response through hf_raise_for_status across the full matrix (hf_hub 0.20.3 → latest) using the same version-aware _to_hf_response helper from fix: classify fallback upstream errors (gated/not-found/unavailable) instead of collapsing to 404 #29:
    • tree all-404 → RepositoryNotFoundError
    • info any-401 → GatedRepoError
    • paths-info all-404 → EntryNotFoundError

Live verification

Against animetimm/mobilenetv3_large_150d.dbv4-full running through the local dev stack:

  • Blob page → Download button triggers the toast "This repository is gated. Attach a Hugging Face token in Settings → Tokens, then retry." and does not open a new tab.
  • Preview dialog still works exactly as before — internally it just delegates to <ErrorState>.

Non-goals

  • No global 404 / 500 route page. Per-page inline errors carry more context and match HF's own UX.
  • No auto-retry on 401 / 403 — they are not transient.
  • No new X-Error-Code emission on endpoints that don't already carry it — backend was already uniform across the surfaces that matter.

Test plan

  • npm test — 263 / 263 green.
  • pytest test/kohakuhub/api/fallback/ test/kohakuhub/api/test_files.py — 101 / 101 green.
  • Live: Download-button toast on gated repo (verified).
  • CI green on full matrix (waiting).

🤖 Generated with Claude Code

Extends the aggregated-fallback contract that landed in #29 beyond
the safetensors/parquet preview dialog, so blob pages, edit pages,
RepoViewer (README + tree), and the blob-page download button all
surface the same HF-compatible classification (gated / not-found /
upstream-unavailable / cors / generic) — no more misleading
"File Not Found" when the real cause was an upstream gate.

## Shared plumbing (new)

- ``src/kohaku-hub-ui/src/utils/http-errors.js``. Central
  classification with two entry points:
    * ``classifyResponse(response)`` — reads a ``fetch`` Response's
      ``X-Error-Code`` / ``X-Error-Message`` headers plus the
      aggregate ``{error, detail, sources[]}`` body, returns
      ``{kind, status, errorCode, detail, sources}``.
    * ``classifyError(err)`` — does the same for an AxiosError /
      ``TypeError`` / SafetensorsFetchError / bare Error. Preserves
      the CORS heuristic (TypeError "Failed to fetch") previously
      inlined in ``FilePreviewDialog.isLikelyCorsError``.
    * Per-kind default copy via ``defaultCopyFor(kind)``, used by
      ErrorState below and the blob download toast.
- ``src/kohaku-hub-ui/src/components/common/ErrorState.vue``. Shared
  panel rendering the classified state — mode=``full-page`` for
  routes, ``inline-panel`` for in-place (README slot, tree slot).
  Renders the ``sources[]`` disclosure from the aggregate body when
  present. Optional ``retry`` callback + ``actions`` slot for
  caller-specific CTAs (e.g. "Open account settings" for gated).
- Axios response interceptor in ``utils/api.js`` populates
  ``err.classification`` so callers can ``catch (err) { render(err.classification) }``
  without re-parsing X-Error-Code every time.

## Consumers (re-wired)

- ``FilePreviewDialog.vue`` — error-state template replaced by
  ``<ErrorState>``; removed the inline ``errorKind`` / ``errorCorsLikely``
  / sources-table logic (300+ lines → 6). Preview-scoped title
  override keeps "File header not found" for per-file misses.
- ``pages/.../blob/[branch]/[...file].vue`` —
    * ``<ErrorState mode="full-page">`` replaces the generic
      "File Not Found" block.
    * A retrieved 404 with no ``X-Error-Code`` still retries (commit
      might be processing), but a 404 **with** a specific code
      short-circuits out of the retry loop.
    * ``downloadFile()`` now probes ``GET``-with-``Range: bytes=0-0``
      before handing the browser off via ``window.open``. On failure
      classifies the response and surfaces an ``ElMessage`` toast
      with a kind-specific CTA — no more raw JSON blob dumped into
      a new browser tab on a gated repo (the original symptom).
- ``pages/.../edit/[branch]/[...file].vue`` — same ``ErrorState``
  treatment; additionally disables the Commit dialog when the file
  never loaded, preventing accidental "overwrite with empty content".
- ``components/repo/RepoViewer.vue`` —
    * ``loadReadme`` now classifies non-2xx responses and shows
      ``<ErrorState mode="inline-panel">`` in the README slot instead
      of the misleading "No README.md found" placeholder.
    * ``loadFileTree`` no longer silently swallows axios failures
      into an empty grid; classification drives an inline ErrorState
      in the file-list slot.

## Backend: extend aggregate contract to tree / info / paths-info

Mirrors #29's ``try_fallback_resolve`` rewrite for the three
remaining fallback operations in
``src/kohakuhub/api/fallback/operations.py``. Every non-2xx source
probe becomes an attempt, the loop keeps going, and if nothing
succeeds the function returns ``build_aggregate_failure_response(attempts)``
instead of ``None``.

One nuance encoded into the helper: ``build_aggregate_failure_response``
now takes ``scope="repo"`` (for info / tree) or ``scope="file"`` (for
resolve / paths-info). Repo-scope all-404 maps to
``RepoNotFound`` → ``RepositoryNotFoundError`` on the hf_hub client;
file-scope all-404 stays ``EntryNotFound`` → ``EntryNotFoundError``.
That matches HF's own semantics — the whole repo missing vs. one
file in an existing repo missing.

``should_retry_source`` is no longer referenced from any of the
four fallback operations; it's kept intact for now to avoid
touching callers outside this diff.

## Tests

**Frontend** (``226 → 263`` total tests after this change):

- ``test/kohaku-hub-ui/utils/test_http_errors.test.js`` — 19 cases
  across ``classifyResponse`` / ``classifyError`` / ``defaultCopyFor``:
  null input, explicit ``X-Error-Code=GatedRepo``, bare 401 without
  code, 403, 404/410 plus EntryNotFound/RepoNotFound/RevisionNotFound,
  5xx, aggregated body, body.error fallback, non-array sources,
  non-JSON bodies, axios-shaped responses, AxiosError in interceptor,
  SafetensorsFetchError, TypeError/Failed-to-fetch as cors, AbortError
  swallowed, plain Error fallback, distinct copy per kind.
- ``test/kohaku-hub-ui/components/test_error_state.test.js`` — 18
  cases: per-kind copy, title/hint overrides, detail row visibility,
  default Retry button wiring, Retry absent when no callback,
  sources[] disclosure, disclosure omitted on empty/invalid,
  custom actions slot replaces default, full-page vs inline mode,
  per-kind icon class.
- Dialog test (``test_file_preview_dialog.test.js``) — updated to
  the new shared copy (e.g. "Request failed" ⇌ old "Preview failed",
  "attach a Hugging Face token" ⇌ old "Attach an access token"),
  all 18 original cases stay green.

**Backend** (``91 → 94`` fallback tests):

- ``test_operations.py`` — the pre-existing ``cached_and_failure_paths``
  test was updated to assert the new aggregated-response shape
  (``info`` + ``tree`` → repo-level codes; ``paths-info`` → per-file).
- ``test_hf_hub_interop.py`` — 3 new pattern-E tests driving the
  aggregate response through ``hf_raise_for_status``:
    * tree all-404 → ``RepositoryNotFoundError`` (not EntryNotFoundError)
    * info any-401 → ``GatedRepoError`` via ``scope="repo"``
    * paths-info all-404 → ``EntryNotFoundError``
  Runs across the full matrix (hf_hub 0.20.3 → latest) via the
  version-aware ``_to_hf_response`` helper from #29.

## Live verification

Against ``animetimm/mobilenetv3_large_150d.dbv4-full`` (gated HF
model reached via the fallback source):

- Blob page → Download button triggers
  ``"This repository is gated. Attach a Hugging Face token in
  Settings → Tokens, then retry."`` toast instead of opening a
  new tab with the raw aggregated-JSON blob.
- Preview dialog on the same file still works (unchanged UX,
  internally now uses the shared ErrorState).
- README on that repo happens to be public on HF — the card tab
  correctly renders it via fallback without triggering the error
  path. When README itself is gated (tested synthetically in unit
  tests), ErrorState displaces the "No README.md found" placeholder.

## Non-goals (not in this PR)

- No global 404 / 500 route page. Per-page inline errors carry
  more context and match HF's own UX.
- No auto-retry on 401/403; they are not transient.
- No new ``X-Error-Code`` emission on endpoints that don't already
  carry it — backend is already uniform across surfaces that matter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@narugo1992
Copy link
Copy Markdown
Author

Tracking issue now open at #31 (GitHub's secondary rate limit on issue creation blocked both surfaces earlier in the session). This PR implements the full 3-step plan in the issue body: plumbing + consumers + backend aggregate parity.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 24, 2026

Codecov Report

❌ Patch coverage is 94.65517% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.37%. Comparing base (b4032cc) to head (c7ccc0b).
⚠️ Report is 1 commits behind head on dev/narugo1992.

Files with missing lines Patch % Lines
src/kohakuhub/api/fallback/operations.py 66.66% 12 Missing ⚠️
src/kohaku-hub-ui/src/utils/http-errors.js 96.68% 10 Missing and 1 partial ⚠️
src/kohaku-hub-ui/src/utils/api.js 71.42% 4 Missing ⚠️
...c/kohaku-hub-ui/src/components/repo/RepoViewer.vue 92.68% 3 Missing ⚠️
.../src/components/repo/preview/FilePreviewDialog.vue 96.77% 1 Missing ⚠️
Additional details and impacted files
@@                Coverage Diff                 @@
##           dev/narugo1992      #30      +/-   ##
==================================================
+ Coverage           94.21%   94.37%   +0.15%     
==================================================
  Files                 119      121       +2     
  Lines               15994    16445     +451     
  Branches              995     1095     +100     
==================================================
+ Hits                15069    15520     +451     
- Misses                914      916       +2     
+ Partials               11        9       -2     
Flag Coverage Δ
backend 95.38% <78.94%> (-0.11%) ⬇️
frontend 92.36% <96.36%> (+0.66%) ⬆️
frontend-admin 99.30% <ø> (ø)
hf0.20.3 95.38% <78.94%> (-0.11%) ⬇️
hf0.30.2 95.38% <78.94%> (-0.11%) ⬇️
hf0.36.2 95.38% <78.94%> (-0.11%) ⬇️
hf1.0.1 95.38% <78.94%> (-0.11%) ⬇️
hf1.6.0 95.38% <78.94%> (-0.11%) ⬇️
hflatest 95.38% <78.94%> (-0.11%) ⬇️
py3.10 95.38% <78.94%> (-0.11%) ⬇️
py3.11 95.35% <78.94%> (-0.11%) ⬇️
py3.12 95.35% <78.94%> (-0.11%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

narugo1992 and others added 4 commits April 24, 2026 18:51
…r them

codecov/patch flagged 92.51% vs 94.21% target on the initial push of
PR #30 — the gap was the new download-button probe logic and the
partial-source-dict fallbacks in ErrorState, both of which lived
inline in their consumers with no direct tests.

Extract the two cleanly-isolable bits into `utils/http-errors.js`:

- `probeUrlAndClassify(url, fetchImpl?)` — the `GET Range: bytes=0-0`
  pre-flight the blob-page Download button used inline. Now a
  testable async helper that returns `{ok, classification}` and
  accepts an injectable `fetchImpl` for the tests to drive.
- `downloadToastFor(classification)` + `DOWNLOAD_TOAST_HINTS` — the
  per-kind toast copy the blob page inlined in a dict literal. Now
  a frozen enum-keyed map so adding a new `ERROR_KIND` forces a
  matching toast entry rather than silently falling through to the
  generic hint.

Blob page `downloadFile()` reduced from ~40 lines of inline probe +
switch to 8 lines calling the two helpers — identical behavior,
fully unit-tested.

Tests (7 new cases in `test_http_errors.test.js` + 1 in
`test_error_state.test.js`):

- `probeUrlAndClassify` ok-path, 401 → gated, transport error →
  cors, and `globalThis.fetch` default when no impl is injected.
- `downloadToastFor` returns a distinct non-empty message for every
  ERROR_KIND and falls back to GENERIC on null / unknown kind.
- `classifyError` detail-falls-to-message branch (err.detail
  missing, err.message populated).
- `ErrorState.vue` renders partial source dicts (missing name /
  status / url / category / message) via the `??` / `typeof`
  fallbacks — regression guard so a future backend that adds / drops
  source fields can't render "undefined" in the disclosure table.

Coverage after this commit:
  http-errors.js   100% lines / 100% funcs (89.71% branches; the
                   only gaps are defensive pre-existing branches
                   outside the new helpers)
  ErrorState.vue   100% / 100% / 100% / 100%

Frontend suite: 226 → 235 passing (+9 new cases this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
codecov/patch still flagged 92.62% vs 94.21% after the
probeUrlAndClassify / downloadToastFor extraction — the residual
gap was the tree-error and README-error catch paths in
RepoViewer.vue that land on the aggregated 401 / 404 / 502
responses from #29 + #30's backend. Those paths had no direct
tests; the existing test_repo_viewer_paths.test.js only drove
the happy-path + an empty-tree case.

Add two cases to the existing mount harness:

- Tree root fetch returns the aggregated-gated body (401 +
  X-Error-Code=GatedRepo + sources[]). Assertions: "Authentication
  required" copy renders via the shared ErrorState, the
  "Fallback sources tried (N)" disclosure shows the source count,
  and the misleading empty-tree "No files" copy is absent.
- README resolve returns the same aggregated-gated body. Assertion:
  the card tab shows ErrorState where the README would otherwise
  sit, and the misleading "No README.md found" placeholder is
  NOT shown (that placeholder implies the repo has no README when
  the reality is just that we couldn't read it).

Both tests use the existing MSW-driven `installBaseHandlers` +
`mountViewer` scaffold from the file; no new test infrastructure.

Frontend suite: 235 → 237 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HuggingFace returns 401 WITHOUT X-Error-Code for non-existent repos
as anti-enumeration; huggingface_hub's hf_raise_for_status maps that
exact shape to RepositoryNotFoundError (see the "401 is misleading"
comment in utils/_http.py). The fallback aggregator and the SPA were
both treating bare 401 as gated, so a typo'd repo URL showed the
"log in with a token" affordance instead of "not found".

This aligns with hf_hub's own heuristic in two places:

- `_categorize_status(status, error_code, ...)` now inspects the
  upstream X-Error-Code header. 401 with `GatedRepo` stays `auth`;
  bare 401 becomes `not-found`.
- `build_aggregate_failure_response` escalates bare-401 attempts to
  `X-Error-Code: RepoNotFound` even on `scope="file"` ops, so the
  client raises `RepositoryNotFoundError` instead of the wrong
  `EntryNotFoundError`. `build_fallback_attempt` persists the
  upstream error code on each attempt for that decision.

Frontend `classifyStatus` follows suit: bare 401 → NOT_FOUND.

Test updates:
- Existing tests that queued bare 401 expecting the auth path now
  pass `X-Error-Code: GatedRepo` on the mock response to stay on
  the genuine-gated path.
- New unit tests cover _categorize_status's header branch,
  build_fallback_attempt's error_code persistence, and
  build_aggregate_failure_response's repo-miss escalation.
- New Pattern F hf_hub interop tests assert that bare 401 on
  resolve and info raises `RepositoryNotFoundError`, matching
  hf_hub's native behavior against a missing repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 302 case to the parametrize so the final `return False`
branch in `should_retry_source` is exercised. Brings
fallback/utils.py to 100% line coverage and nudges the
codecov/project ratio back above the previous baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@narugo1992 narugo1992 merged commit 284cf07 into dev/narugo1992 Apr 24, 2026
21 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant