Current design
HttpResponseError is generic over a deserialised-body type so that consumers decoding a known error schema can write HttpResponseError[MyModel] and read a typed model attribute.
packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/http.py:
- The class is
class HttpResponseError(SdkError, Generic[ModelT]) (line 39), with model: ModelT | None as a declared attribute (line 69) and self.model = kwargs.pop("model", None) in __init__ (line 93).
- Supporting that generic costs us a
# noqa: UP046 on the class line and a split ModelT definition (lines 20-28): a TypeVar(..., default=Any) under TYPE_CHECKING plus a plain TypeVar at runtime, because PEP 696 defaults are only accepted by the runtime typing.TypeVar on 3.13+ while we still support 3.12.
The shipped path that turns a non-2xx response into one of these exceptions is map_error in errors/error_map.py:
raise error_type(response=response) # error_map.py:42
It constructs the exception with response= only — no model, and no seam to supply one. The documented usage pattern (docs/errors.md, lines 52-55) routes every call through map_error after a non-success send.
A repo-wide search confirms nothing in core ever passes model=. The only SDK-internal constructions are in http/sse/connection.py (lines 215, 391), and both pass response alone.
Trade-off / concern
For every path the SDK actually drives, the generic parameter is decorative: a consumer using the documented pattern gets model is None 100% of the time, regardless of what they write for HttpResponseError[MyModel]. The only way to populate model is to bypass map_error and hand-construct the exception — at which point a plain attribute would do exactly the same work, and the generic delivers no checked guarantee the supported path can honour.
Meanwhile the generic isn't free. Beyond the noqa and the 3.12/3.13 TypeVar split above, Generic on an exception type carries a language-level wrinkle: a parametrised generic can't appear in an except clause, so except HttpResponseError[MyModel]: is not legal and callers catch the unparametrised form anyway. The headline ergonomic — typed error bodies — is unreachable through the one mapping helper we ship.
Proposed direction
Two coherent options, pick one:
(a) Give map_error a decoding seam so the typed payload is reachable through the supported path:
def map_error(
status_code: int,
response: Response,
error_map: Mapping[int, type[HttpResponseError]] | None,
*,
decoder: Callable[[Response], ModelT] | None = None,
) -> None:
...
err = error_type(response=response)
if decoder is not None:
err.model = decoder(response)
raise err
Trade-off: the decoder runs against an unbuffered body, so map_error stops being a pure status lookup and becomes something that consumes the response. That is a real behavioural shift and would need to be documented (and the model attribute would have to be writable post-construction, or the decode threaded through the constructor).
(b) Drop the Generic and keep model: Any | None as a plain attribute. This removes the noqa, the PEP 696 dual-TypeVar dance, and the generic-on-Exception friction, and loses nothing real, since the typing delivers no checked guarantee to a caller who uses the shipped helper. Consumers that hand-build the exception still set model; they just annotate it themselves.
My lean is (b) unless we intend to commit to (a), because today we pay the generic's full syntactic cost for an ergonomic the supported code path can't deliver.
Acknowledging the current rationale
The CHANGELOG's "Honest scope boundaries" deliberately defers a default error map — "error classification beyond the retryable flag and body snapshot was deferred; callers still map status codes to domain errors themselves." That boundary is sound and is not what this raises. The deferred default map is about unmatched-code fallback; this is about the internal coherence of the model generic against the matching helper we do ship. Even with the default map deferred, the generic typing and map_error should agree on whether a typed body is reachable — right now they don't, and the generic pays a syntactic tax for a guarantee the supported path can't keep.
Current design
HttpResponseErroris generic over a deserialised-body type so that consumers decoding a known error schema can writeHttpResponseError[MyModel]and read a typedmodelattribute.packages/dexpace-sdk-core/src/dexpace/sdk/core/errors/http.py:class HttpResponseError(SdkError, Generic[ModelT])(line 39), withmodel: ModelT | Noneas a declared attribute (line 69) andself.model = kwargs.pop("model", None)in__init__(line 93).# noqa: UP046on the class line and a splitModelTdefinition (lines 20-28): aTypeVar(..., default=Any)underTYPE_CHECKINGplus a plainTypeVarat runtime, because PEP 696 defaults are only accepted by the runtimetyping.TypeVaron 3.13+ while we still support 3.12.The shipped path that turns a non-2xx response into one of these exceptions is
map_errorinerrors/error_map.py:It constructs the exception with
response=only — nomodel, and no seam to supply one. The documented usage pattern (docs/errors.md, lines 52-55) routes every call throughmap_errorafter a non-success send.A repo-wide search confirms nothing in
coreever passesmodel=. The only SDK-internal constructions are inhttp/sse/connection.py(lines 215, 391), and both passresponsealone.Trade-off / concern
For every path the SDK actually drives, the generic parameter is decorative: a consumer using the documented pattern gets
model is None100% of the time, regardless of what they write forHttpResponseError[MyModel]. The only way to populatemodelis to bypassmap_errorand hand-construct the exception — at which point a plain attribute would do exactly the same work, and the generic delivers no checked guarantee the supported path can honour.Meanwhile the generic isn't free. Beyond the
noqaand the 3.12/3.13 TypeVar split above,Genericon an exception type carries a language-level wrinkle: a parametrised generic can't appear in anexceptclause, soexcept HttpResponseError[MyModel]:is not legal and callers catch the unparametrised form anyway. The headline ergonomic — typed error bodies — is unreachable through the one mapping helper we ship.Proposed direction
Two coherent options, pick one:
(a) Give
map_errora decoding seam so the typed payload is reachable through the supported path:Trade-off: the decoder runs against an unbuffered body, so
map_errorstops being a pure status lookup and becomes something that consumes the response. That is a real behavioural shift and would need to be documented (and themodelattribute would have to be writable post-construction, or the decode threaded through the constructor).(b) Drop the
Genericand keepmodel: Any | Noneas a plain attribute. This removes thenoqa, the PEP 696 dual-TypeVar dance, and the generic-on-Exceptionfriction, and loses nothing real, since the typing delivers no checked guarantee to a caller who uses the shipped helper. Consumers that hand-build the exception still setmodel; they just annotate it themselves.My lean is (b) unless we intend to commit to (a), because today we pay the generic's full syntactic cost for an ergonomic the supported code path can't deliver.
Acknowledging the current rationale
The CHANGELOG's "Honest scope boundaries" deliberately defers a default error map — "error classification beyond the
retryableflag and body snapshot was deferred; callers still map status codes to domain errors themselves." That boundary is sound and is not what this raises. The deferred default map is about unmatched-code fallback; this is about the internal coherence of themodelgeneric against the matching helper we do ship. Even with the default map deferred, the generic typing andmap_errorshould agree on whether a typed body is reachable — right now they don't, and the generic pays a syntactic tax for a guarantee the supported path can't keep.