Skip to content

organizations.members.get raises a raw pydantic.ValidationError when the API returns a success status with member and organization set to null #319

Description

@elspacewalk

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 mainGetResponse 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 < 400from_json raises ResponseErrorStytchError 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

  1. 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.
  2. 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.
  3. 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:

  1. Type the fields to match what the API can returnmember: 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions