fix(portability): code every export/import refusal, and stop rendering 5xx boilerplate - #6066
fix(portability): code every export/import refusal, and stop rendering 5xx boilerplate#6066leonlaiyc wants to merge 2 commits into
Conversation
6d70bef to
74f17c4
Compare
9475834 to
639768b
Compare
639768b to
ac67704
Compare
5e0b85d to
c07de0b
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Rebased onto current main and resolved the conflict. The only conflict was the generated error-code-baseline.json; I took main's copy and then regenerated it with python test/test_error_code_contract.py --update so the counts describe the actual post-rebase tree rather than either side's stale snapshot. The ratchet still moves in the only permitted direction. Verified locally: the error-code contract suite and this PR's own API tests pass. Approving.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
…g 5xx boilerplate
`error-code-baseline.json` listed `dashboard/handlers/portability.py` with
`missing_code: 9` — nine refusals whose only identity was an English sentence.
The file already had the pattern: its 413 answers
`{"error": "archive too large (max 2048 MB)", "code": "import_archive_too_large"}`.
The other nine now follow it — `auth_required` (the wire name from
`handlers_cloud.py`), `file_field_required`, `invalid_import_mode`,
`import_archive_invalid`, and one `<operation>_failed` per handler.
The two kinds of 4xx here are deliberately kept apart. `import_archive_invalid`
carries the archive validator's own finding ("manifest.json is missing"), which
is the whole value of that message; the 5xx bodies carry opaque boilerplate
("Export failed") that exists precisely so the cause stays private.
`PortabilityTab` renders `data.error || <localized fallback>`, so it preferred
that English boilerplate over a catalog string that says the same thing in the
reader's language. `refusalText()` inverts it for exactly one case — a refusal
that is BOTH coded and 5xx — leaving 4xx detail and uncoded refusals of any
status untouched. The export path is not moved: its fallback is
`resp.statusText`, so there is no localized string to prefer, and writing one
is new user-facing copy rather than this contract.
Drive-to-green (rebase onto current main): original work by Leon (leonlaiyc);
resolved the error-code-baseline.json conflict by taking main's counters and
regenerating with `python test/test_error_code_contract.py --update`
(missing_code 1262 -> 1253, the portability entry deleted). All source changes
kept verbatim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
…idence Two CI gates on the rebased head, both of them fair. Coverage Gate: this PR's new test/test_api_portability.py takes dashboard/handlers/portability.py from 17.0% to 82.4%, above the 80% per-file floor, so its baseline entry has to go or the baseline stops shrinking. Pruned with the repo's own updater against THIS run's coverage artifact (a local coverage run has different rates and would prune the wrong entries); the refresh only deletes, so exactly one line is removed. Screenshot Evidence: the PR changes PortabilityTab.tsx, a user-visible surface, and the change IS visible -- on a 5xx carrying a code the banner now renders the localized catalog string instead of the server's English boilerplate. Captured from the real component with real styles and the real i18n catalog, driving the real file-input handler; only the network response is stubbed, since a specific server response is what the change is about. The Japanese pair is the telling one: before, a fully translated panel with a bare English "Preview failed". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c07de0b to
a270079
Compare
|
Rebased onto main Conflicts resolved (both generated baselines, took main's version and re-applied only this PR's own edit):
Gates run locally on changed files: black, isort, flake8, Please review the resolution. A maintainer push makes the maintainer the last pusher, so a second approver is needed under the repo's last-push rule. Reply if anything looks wrong. |
Problem / Motivation
AGENTS.mdstates the rule directly: "Backend-owned strings have no catalogpath yet, so a new non-2xx JSON body MUST carry a machine-readable
codefield."
error-code-baseline.json— the worklist that ratchets the existingdebt down — listed
dashboard/handlers/portability.pywithmissing_code: 9.The file already had the answer in it. Its 413 answers:
{"error": "archive too large (max 2048 MB)", "code": "import_archive_too_large"}The other nine refusals across export, import and preview carried prose only.
Why it matters
Two different failures are indistinguishable to a client today. A 400 from
/api/portability/importis either a rejected mode or a rejected archive; a 500from any of the three endpoints is opaque by design. And
PortabilityTabrenders
data.error || <localized fallback>, so the English produced in Pythonwins over a catalog string that says the same thing in the reader's language —
in a dashboard translated into 12.
What changed (motivation → approach → change)
Backend. The nine refusals gain codes in the vocabulary already used here
and elsewhere in the tree:
auth_required(the wire name fromhandlers_cloud.py, on all three endpoints),file_field_required,invalid_import_mode,import_archive_invalid, and one<operation>_failedper handler —
export_failed,import_failed,preview_failed. Every prosestring is unchanged.
The two kinds of 4xx are deliberately kept apart, and this is the decision
the rest of the PR follows from:
import_archive_invalidcarries the archive validator's own finding —"manifest.json is missing". That prose is the value of the message."Export failed"— which is opaque onpurpose, so the cause stays private.
Frontend.
refusalText()inverts the preference for exactly one case: arefusal that is both coded and 5xx. There the localized fallback wins,
because the server's string says strictly less than the fallback already says.
A coded 4xx keeps its detail. An uncoded refusal at any status keeps its
message — it may be from a proxy or an edge rather than these handlers, and
there the prose can be all there is.
That is one predicate on
(code, status), not a hand-maintained list of codesthe frontend has to keep in sync with the backend.
The export path is deliberately not moved. Its fallback is
resp.statusText, not a catalog string, so there is nothing localized toprefer. Giving it one means authoring new user-facing copy in 12 languages,
which is a different change from this contract.
The user-visible effect is confined to two error messages in the Portability
tab: where a failed import or preview read
Import failed/Preview failedin English, it now reads the tab's own localized string. No panel, component,
layout or theme changed.
Baseline
missing_code1262 -> 1253 -- exactly the nine -- and thedashboard/handlers/portability.pyentry is gone._compliantmoves1410 -> 1419, exactly +9.
(Numbers above reflect the 2026-08-30 rebase onto
main750e4b15c; thecommitted baseline there was already fresh, so both deltas are exactly this
PR's ±9 with no inherited refresh. An earlier revision of this section
described the older
420dbebcfrebase state.)Rebased onto current
main750e4b15c. Thebaseline was regenerated from the live tree with the repo's own
python test/test_error_code_contract.py --update, never hand-merged: thetotals in this file are relative to this PR's base, so adding or reconciling
numbers across branches would be meaningless. Diffing the regenerated file
against
main's shows exactly one changed entry --dashboard/handlers/portability.py, removed -- and no other file added,removed, or changed.
missing_code, the number the gate enforces, moves byexactly nine.
Tests
test/test_api_portability.pyis new — the existingtest_portability.pycovers the zip logic, and nothing exercised these handlers over HTTP. It drives
them through an aiohttp
TestServerwith real multipart uploads, so the authand file-part paths are the ones a client actually hits.
test_every_endpoint_denies_anonymously_with_auth_requiredauth_requiredon all three endpointstest_export_failure_is_coded_and_stays_opaqueexport_failed, prose still generic, private path still absenttest_an_unrecognized_import_mode_is_codedinvalid_import_mode, four rejected modestest_an_upload_without_the_file_part_is_codedfile_field_requiredon both upload endpointstest_a_rejected_archive_keeps_the_validator_detailimport_archive_invalidalongside the validator's message — the case the frontend rule turns ontest_import_failure_is_coded/test_preview_failure_is_codedimport_failed/preview_failed, cause still not leakedtest_every_refusal_carries_a_codeRed before: with only the handler change stashed, all 14 cases fail.
Green after: 14 pass.
website/src/pages/overview/PortabilityTab.test.tsxcoversrefusalTextdirectly — 4 cases, including the two negative controls that define the rule's
edges: a coded 4xx keeps its detail, and an uncoded 5xx keeps its message.
Other gates:
test_portability.pyandtest_error_code_contract.pypassalongside the new module (68 passed / 3 skipped).
tsc -bandeslintclean;flake8,isort,mypyclean;scripts/check_black_formatting.pypasses onthe 5 changed files.
Coverage
The rebased head also tripped the Coverage Gate, in the good direction: this PR's new
test/test_api_portability.pytakesdashboard/handlers/portability.pyfrom 17.0% to 82.4%, above the 80% per-file floor, so the gate asked for its.github/coverage-baselines/backend.txtentry to be removed or the baseline stops shrinking. Pruned with the repo's own updater against that run's coverage artifact rather than a local run, whose rates differ and would prune the wrong entries. The refresh only deletes, so the diff is exactly one line.Manual verification
N/A — unit coverage sufficient: the backend tests exercise the real handlers
over HTTP including multipart, and the one user-visible surface is a string
chosen by
refusalText, which is asserted directly at every branch.Screenshots / video
The frontend half changes what the error banner renders for a
5xxthat carries acode: the server's English boilerplate is replaced by the localized catalog string. The Japanese pair is the one that shows why it matters — before, a fully translated panel with a bare English sentence in it.ja-JP)main) — server prose:Preview failed無効なアーカイブSame pair in English, where the delta is smaller but present (
Preview failed→Invalid archive)en-US)main)How these were captured, stated plainly: the real
PortabilityTabcomponent, the realsrc/index.css, and the real i18n catalog, with the real file-inputonChangehandler driven to run the real preview path. Only the network response is stubbed — to{"ok": false, "error": "Preview failed", "code": "preview_failed"}with status500— because a specific server response is precisely what this change is about. The capture harness was temporary and is not in the diff; only the four PNGs are, under the ephemeral top-leveltemp-screenshots/the description contract specifies.Related Issues
No issue — this is the
dashboard/handlers/portability.pyline item fromerror-code-baseline.json's own worklist ("drivemissing_codeto zero, one PRper file or per directory, moving each frontend consumer in the same PR").
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement