fix(samples): parse the unified v2 error envelope on import failures - #21
Conversation
flow-api's v2 API now returns every error as a {code, message, details}
envelope under "error", where it previously returned a bare string. The
transport blindly took body["error"] as the message, so a bad sample import
surfaced as a raw Python dict repr in the CLI and the per-field details list
— the whole point of the server-side unification — never reached the renderer.
Unwrap the envelope in one place in the transport (covering both
/v2/sample-imports endpoints), carry its details on FlowApiError, and render
each detail on its own field-prefixed line. Legacy endpoints returning a flat
string, and the annotation endpoint's {"validation"/"warnings": [...]} dicts,
are untouched: the unwrap only fires when "error" is a dict with a "message"
key, which only the v2 envelope produces.
Bump to 0.11.1 (backwards-compatible fix).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| 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 |
There was a problem hiding this comment.
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,
)| 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).
| self, | ||
| status_code: int, | ||
| message: str | dict[str, list[str]], | ||
| details: list[dict] | None = None, |
There was a problem hiding this comment.
OPTIONAL — list[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.
| 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.
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"anddetails == [{...}](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 yieldsdetails is None. Existing transport tests would catch a brokenmessage, but nothing would catchdetailsstarting 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.
What
flow-api master now returns a unified error envelope for every v2 endpoint:
{"error": {"code": "validation_error", "message": "Invalid sample import request", "details": [{"field": "0.sample_type", "code": "invalid", "message": "Sample type 'bogus' does not exist"}]}}Previously
errorwas a bare string. The client's transport tookbody["error"]verbatim as the error message, so on a bad sample import the CLI dumped a raw Python dict repr and the per-fielddetailslist — the whole point of the server-side unification — never reached the renderer.Change
_transport.py—_unpack_error_bodydetects the{code, message, details}envelope, extracting the human message and details in one place that covers both/v2/sample-importsendpoints (import_samples,get_import).exceptions.py—FlowApiErrorcarries optionaldetails._main.py— passeserror.detailsto the renderer (was AnnotationValidationError-only)._output.py—format_issueprefixes envelope details with theirfield; annotationrow N:rendering preserved.0.11.0→0.11.1.A bad import now renders:
Compatibility
Backwards-compatible. The unwrap only fires when
erroris a dict with amessagekey — which only the v2 envelope produces. Legacy (non-v2) endpoints returning flat strings, and the annotation endpoint's{"validation"/"warnings": [...]}dicts, are unaffected. All 393 unit tests pass.🤖 Generated with Claude Code