-
Notifications
You must be signed in to change notification settings - Fork 0
fix(samples): parse the unified v2 error envelope on import failures #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,11 +17,20 @@ class FlowApiError(Exception): | |
| :param status_code: The HTTP status code from the response. | ||
| :param message: The error message — either a string or a dict of | ||
| field-level errors (e.g. ``{"field": ["error message"]}``). | ||
| :param details: Optional per-field problems from the server's error | ||
| envelope, each a ``{"field", "code", "message"}`` dict. Set when the | ||
| response carries the structured ``details`` list; ``None`` otherwise. | ||
| """ | ||
|
|
||
| def __init__(self, status_code: int, message: str | dict[str, list[str]]) -> None: | ||
| def __init__( | ||
| self, | ||
| status_code: int, | ||
| message: str | dict[str, list[str]], | ||
| details: list[dict] | None = None, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OPTIONAL — A |
||
| ) -> None: | ||
| self.status_code = status_code | ||
| self.message = message | ||
| self.details = details | ||
| super().__init__(str(message)) | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1151,10 +1151,17 @@ def test_trailing_blank_row_is_skipped( | |
| def test_api_rejection_propagates_as_error( | ||
| self, run_cli, tmp_path: Path, | ||
| ) -> None: | ||
| detail_message = "Sample type 'bogus' does not exist" | ||
| route = respx.post(SAMPLE_IMPORTS_URL).mock( | ||
| return_value=httpx.Response( | ||
| HTTPStatus.UNPROCESSABLE_ENTITY, | ||
| json={"error": "sample type 'bogus' does not exist"}, | ||
| json={"error": { | ||
| "code": "validation_error", | ||
| "message": "Invalid sample import request", | ||
| "details": [ | ||
| {"field": "0.sample_type", "code": "invalid", "message": detail_message}, | ||
| ], | ||
| }}, | ||
| ), | ||
| ) | ||
| sheet = _write_import_sheet(tmp_path, _import_record(sample_type="bogus")) | ||
|
|
@@ -1165,7 +1172,9 @@ def test_api_rejection_propagates_as_error( | |
|
|
||
| assert result.exit_code == 1 | ||
| assert route.call_count == 1 | ||
| assert "bogus" in result.stderr | ||
| assert "Invalid sample import request" in result.stderr | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BLOCKING — the new behaviour is only asserted through CLI stderr text on two sample-import commands, but the unwrap lives in the transport and
Exercising through the CLI is fine per CLAUDE.md, but |
||
| assert f"0.sample_type: {detail_message}" in result.stderr | ||
| assert "{'" not in result.stderr | ||
|
|
||
| @respx.mock | ||
| def test_non_csv_sheet_is_usage_error(self, run_cli, tmp_path: Path) -> None: | ||
|
|
@@ -1616,9 +1625,11 @@ def test_reports_job_with_no_execution_yet(self, run_cli) -> None: | |
|
|
||
| @respx.mock | ||
| def test_unknown_job_id_is_not_found(self, run_cli) -> None: | ||
| not_found_message = "sample import 999 does not exist" | ||
| respx.get(f"{SAMPLE_IMPORTS_URL}/999").mock( | ||
| return_value=httpx.Response( | ||
| HTTPStatus.NOT_FOUND, json={"error": "sample import 999 does not exist"}, | ||
| HTTPStatus.NOT_FOUND, | ||
| json={"error": {"code": "not_found", "message": not_found_message, "details": []}}, | ||
| ), | ||
| ) | ||
|
|
||
|
|
@@ -1627,6 +1638,8 @@ def test_unknown_job_id_is_not_found(self, run_cli) -> None: | |
| ) | ||
|
|
||
| assert result.exit_code == 4 | ||
| assert not_found_message in result.stderr | ||
| assert "{'" not in result.stderr | ||
|
|
||
| def test_missing_job_id_is_usage_error(self, run_cli) -> None: | ||
| result = run_cli("--token", TOKEN, "samples", "import-status") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OPTIONAL — now that
FlowApiErrorowns adetailschannel, there are two attributes carrying the same concept and the caller has toisinstance-switch between them.AnnotationValidationErrorsetsself.errorsbut leavesself.detailsatNone, so "an annotation error has no details" is a representable-but-wrong state that only this branch papers over — and any future call site that readserror.detailswill silently lose annotation errors.Passing them through the base channel makes the switch unnecessary:
.errorsstays as the specific, tested alias for library users;_dispatchstops needing to know the subclass exists (and theAnnotationValidationErrorimport here likely becomes unused).