Summary
B2BOrganizationsMembers.get / .get_async types member and organization as required on
GetResponse, but the API can answer a member lookup with a non-error HTTP status and both objects
explicitly null. Because ResponseBase.from_json only converts the body into a StytchError when
status_code >= 400, the success path falls through to cls(**json) and pydantic raises.
The caller gets a bare pydantic.ValidationError escaping from the SDK — not a StytchError, and not a
response object — so there is no supported way to handle it.
Environment
|
|
stytch |
15.2.0 (also present on v15.3.0 and main — GetResponse is unchanged) |
pydantic |
2.12.5 |
| Python |
3.14 |
| Endpoint |
GET /v1/b2b/organizations/{organization_id}/member |
What we observed in production
ValidationError: 2 validation errors for GetResponse
member
Input should be a valid dictionary or instance of Member [type=model_type, input_value=None, input_type=NoneType]
organization
Input should be a valid dictionary or instance of Organization [type=model_type, input_value=None, input_type=NoneType]
File "stytch/b2b/api/organizations_members.py", line 1272, in get_async
return GetResponse.from_json(res.response.status, res.json)
File "stytch/core/response_base.py", line 22, in from_json
return cls(**json)
Two things are worth pulling out of that error, because they pin down the response shape without us
having captured the raw body:
- The error type is
model_type with input_value=None, not missing. Pydantic only reports
model_type/None when the keys are present with explicit JSON nulls; omitted keys produce
type=missing. So the body contained "member": null, "organization": null.
status_code, request_id and member_id all validated, so the envelope itself was well-formed, and
the HTTP status was < 400 — from_json raises ResponseError → StytchError before ever calling
cls(**json) for >= 400.
Minimal reproduction
No credentials or network needed — this reproduces the production error exactly:
from stytch.b2b.models.organizations_members import GetResponse
GetResponse.from_json(
200,
{
"status_code": 200,
"request_id": "request-id-test-00000000-0000-0000-0000-000000000000",
"member_id": "member-test-00000000-0000-0000-0000-000000000000",
"member": None,
"organization": None,
},
)
pydantic_core._pydantic_core.ValidationError: 2 validation errors for GetResponse
member
Input should be a valid dictionary or instance of Member [type=model_type, input_value=None, input_type=NoneType]
organization
Input should be a valid dictionary or instance of Organization [type=model_type, input_value=None, input_type=NoneType]
How we hit it
We consume Stytch SCIM webhooks. Webhooks carry no ordering guarantee, so we treat each event as a
signal and re-fetch the member's current state by member_id to converge on it. When the member has
already been deprovisioned by the time we fetch, the lookup returns this success-with-nulls body instead
of a 404, and the activity crashes.
Our client already maps StytchError with status_code == 404 to None for the not-found case, so had
the API answered 404 here it would have been handled.
Why this is awkward for callers
ValidationError is not part of the SDK's error contract. Callers guard SDK calls with
except StytchError. A pydantic exception bypasses every one of those handlers and surfaces as an
unhandled crash.
- There is no way to tell "no such member" from "the SDK could not parse a real response." Both
arrive as ValidationError. Any workaround has to introspect pydantic's errors() payload, which is
not a stable interface.
- The exception text embeds the raw input. For the all-null case that is harmless, but the same
code path stringifies whatever was in the response for any other parse failure, which makes the
exception message risky to log for an endpoint that returns member PII.
Expected behaviour
Any of these would resolve it; ordered by what we'd find most useful:
- Type the fields to match what the API can return —
member: Optional[Member] and
organization: Optional[Organization] on GetResponse, letting callers branch on
response.member is None. These models look code-generated, so this may belong in the upstream API
definition rather than in this repo directly.
- Normalise it to a
StytchError in from_json (or in the members API layer) so it joins the
documented error contract, ideally with a member_not_found error type.
- Have the API return
404 for this case, consistent with the not-found response the SDK already
documents — if the success-with-nulls body is itself the bug, this is the real fix and 1/2 are just
hardening.
Whatever the resolution, we'd suggest from_json not let a bare ValidationError escape — wrapping
parse failures in an SDK-owned exception type would at least make them catchable without depending on
pydantic internals.
Note that Members.dangerously_get shares GetResponse and so has the same exposure.
Our current workaround
For anyone who lands here first — we catch the ValidationError and treat the specific
both-fields-null shape as "member absent", re-raising anything else:
except ValidationError as e:
details = e.errors()
error_locations = {detail["loc"] for detail in details}
member_absent = error_locations == {("member",), ("organization",)} and all(
detail["input"] is None for detail in details
)
if not member_absent:
raise
return None
This is deliberately narrow — a response where only one of the two is null, or where member is present
but unparseable, still raises — but it depends on the shape of pydantic's error list, which is exactly
the sort of thing we'd rather not pin our auth path to.
Summary
B2BOrganizationsMembers.get/.get_asynctypesmemberandorganizationas required onGetResponse, but the API can answer a member lookup with a non-error HTTP status and both objectsexplicitly
null. BecauseResponseBase.from_jsononly converts the body into aStytchErrorwhenstatus_code >= 400, the success path falls through tocls(**json)and pydantic raises.The caller gets a bare
pydantic.ValidationErrorescaping from the SDK — not aStytchError, and not aresponse object — so there is no supported way to handle it.
Environment
stytchv15.3.0andmain—GetResponseis unchanged)pydanticGET /v1/b2b/organizations/{organization_id}/memberWhat we observed in production
Two things are worth pulling out of that error, because they pin down the response shape without us
having captured the raw body:
model_typewithinput_value=None, notmissing. Pydantic only reportsmodel_type/Nonewhen the keys are present with explicit JSONnulls; omitted keys producetype=missing. So the body contained"member": null, "organization": null.status_code,request_idandmember_idall validated, so the envelope itself was well-formed, andthe HTTP status was
< 400—from_jsonraisesResponseError→StytchErrorbefore ever callingcls(**json)for>= 400.Minimal reproduction
No credentials or network needed — this reproduces the production error exactly:
How we hit it
We consume Stytch SCIM webhooks. Webhooks carry no ordering guarantee, so we treat each event as a
signal and re-fetch the member's current state by
member_idto converge on it. When the member hasalready been deprovisioned by the time we fetch, the lookup returns this success-with-nulls body instead
of a
404, and the activity crashes.Our client already maps
StytchErrorwithstatus_code == 404toNonefor the not-found case, so hadthe API answered
404here it would have been handled.Why this is awkward for callers
ValidationErroris not part of the SDK's error contract. Callers guard SDK calls withexcept StytchError. A pydantic exception bypasses every one of those handlers and surfaces as anunhandled crash.
arrive as
ValidationError. Any workaround has to introspect pydantic'serrors()payload, which isnot a stable interface.
code path stringifies whatever was in the response for any other parse failure, which makes the
exception message risky to log for an endpoint that returns member PII.
Expected behaviour
Any of these would resolve it; ordered by what we'd find most useful:
member: Optional[Member]andorganization: Optional[Organization]onGetResponse, letting callers branch onresponse.member is None. These models look code-generated, so this may belong in the upstream APIdefinition rather than in this repo directly.
StytchErrorinfrom_json(or in the members API layer) so it joins thedocumented error contract, ideally with a
member_not_founderror type.404for this case, consistent with the not-found response the SDK alreadydocuments — if the success-with-nulls body is itself the bug, this is the real fix and 1/2 are just
hardening.
Whatever the resolution, we'd suggest
from_jsonnot let a bareValidationErrorescape — wrappingparse failures in an SDK-owned exception type would at least make them catchable without depending on
pydantic internals.
Note that
Members.dangerously_getsharesGetResponseand so has the same exposure.Our current workaround
For anyone who lands here first — we catch the
ValidationErrorand treat the specificboth-fields-null shape as "member absent", re-raising anything else:
This is deliberately narrow — a response where only one of the two is null, or where
memberis presentbut unparseable, still raises — but it depends on the shape of pydantic's error list, which is exactly
the sort of thing we'd rather not pin our auth path to.