Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion flowbio/cli/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def _dispatch(args: argparse.Namespace) -> int:
output.emit_error(str(error))
return int(ExitCode.USAGE)
except FlowApiError as error:
details = error.errors if isinstance(error, AnnotationValidationError) else None
details = error.errors if isinstance(error, AnnotationValidationError) else error.details

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OPTIONAL — now that FlowApiError owns a details channel, there are two attributes carrying the same concept and the caller has to isinstance-switch between them. AnnotationValidationError sets self.errors but leaves self.details at None, 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 reads error.details will silently lose annotation errors.

Passing them through the base channel makes the switch unnecessary:

# exceptions.py
def __init__(self, errors: list[dict]) -> None:
    self.errors = errors
    super().__init__(
        HTTPStatus.BAD_REQUEST,
        f"Annotation has {len(errors)} validation error(s)",
        details=errors,
    )
Suggested change
details = error.errors if isinstance(error, AnnotationValidationError) else error.details
details = error.details

.errors stays as the specific, tested alias for library users; _dispatch stops needing to know the subclass exists (and the AnnotationValidationError import here likely becomes unused).

output.emit_error(
error.message, status_code=error.status_code, details=details,
)
Expand Down
15 changes: 11 additions & 4 deletions flowbio/cli/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,20 @@ def emit_raw(self, body: str) -> None:


def format_issue(issue: JsonValue) -> str:
"""Render a Flow ``{row, message}`` issue dict as a readable line.
"""Render a Flow issue dict as a readable line.

Falls back to ``str`` for any other shape so unexpected payloads still
surface intact.
Handles both the annotation ``{row, message}`` shape and the API error
envelope's ``{field, code, message}`` detail shape, prefixing with whichever
locator is present. Falls back to ``str`` for any other shape so unexpected
payloads still surface intact.
"""
if isinstance(issue, dict) and "message" in issue:
row = issue.get("row")
prefix = f"row {row}: " if row is not None else ""
if row is not None:
prefix = f"row {row}: "
elif issue.get("field") is not None:
prefix = f"{issue['field']}: "
else:
prefix = ""
return f"{prefix}{issue['message']}"
return str(issue)
16 changes: 14 additions & 2 deletions flowbio/v2/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,30 @@ def _raise_for_error(self, response: httpx.Response) -> None:
if response.is_success:
return

details: list[dict] | None = None
try:
body = response.json()
except ValueError:
# Upstream proxies (Cloudflare, GCP load balancers, nginx) return
# HTML/plain-text on 5xx instead of the API's JSON envelope.
message = self._non_json_error_message(response)
else:
message = body.get("error", body)
message, details = self._unpack_error_body(body)
exception_class = self._STATUS_TO_EXCEPTION.get(
response.status_code, FlowApiError,
)
raise exception_class(response.status_code, message)
raise exception_class(response.status_code, message, details)

@staticmethod
def _unpack_error_body(body: dict) -> tuple[str | dict, list[dict] | None]:
error = body.get("error", body)
# The v2 API wraps errors in a {code, message, details} envelope; older
# endpoints put a bare string (or their own dict) under "error". Unwrap
# the envelope to its human message and per-field details, and leave any
# other shape untouched.
if isinstance(error, dict) and "message" in error:
return error["message"], error.get("details") or None
return error, None

@staticmethod
def _non_json_error_message(response: httpx.Response) -> str:
Expand Down
11 changes: 10 additions & 1 deletion flowbio/v2/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OPTIONALlist[dict] is a new public attribute typed as an unparameterised dict, while the docstring right above it states the actual shape ({"field", "code", "message"}). CLAUDE.md's typing rule is to type the actual shape rather than a catch-all, and the shape currently only exists as prose — _output.format_issue then rediscovers it by probing keys at runtime.

A TypedDict (or a frozen model, matching the convention elsewhere in v2) would carry it in the type system: class ErrorDetail(TypedDict): field: str; code: str; message: str, then details: list[ErrorDetail] | None. Worth doing here because the envelope is now the server's standard error shape, so this list will be read by SDK users directly, not just by the CLI renderer.

) -> None:
self.status_code = status_code
self.message = message
self.details = details
super().__init__(str(message))


Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setup(
name="flowbio",
version="0.11.0",
version="0.11.1",
description="A client for the Flow API.",
long_description=long_description,
long_description_content_type="text/markdown",
Expand Down
19 changes: 16 additions & 3 deletions tests/unit/cli/test_samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 FlowApiError.details is a public attribute of a client library. tests/unit/v2/test_transport.py and tests/unit/v2/test_exceptions.py currently contain no reference to details at all, so nothing pins:

  • the positive library-level case — a v2 envelope response raises with message == "Invalid sample import request" and details == [{...}] (this is what an SDK consumer, as opposed to the CLI, actually reads);
  • the compat case the PR description rests on — a legacy {"error": "flat string"} body still yields details is None. Existing transport tests would catch a broken message, but nothing would catch details starting to pick up junk from a non-envelope body.

Exercising through the CLI is fine per CLAUDE.md, but transport.post/get are public entry points too and test_transport.py is where every other status-to-exception mapping is pinned. Two small tests there (envelope → populated details; flat string → None) would close it.

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:
Expand Down Expand Up @@ -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": []}},
),
)

Expand All @@ -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")
Expand Down
Loading