Skip to content

Allow fork response types (e.g. httpx2.Response) in return_value / side_effect #324

Description

@gaborbernat

respx mocks httpx2 at the transport layer via using="httpcore2", and pytest-httpx2 ships that mocker. When a test hands a response object to a route, respx still requires httpx.Response and rejects httpx2.Response, even though the request runs through httpx2.

Reproduce

import httpx2
import respx

@respx.mock(using="httpcore2")
def test_retry():
    respx.get("https://example.com").mock(
        side_effect=[
            httpx2.ReadTimeout("timed out"),
            httpx2.Response(200, text="ok"),  # rejected
        ]
    )
    httpx2.get("https://example.com")

This fails at runtime, not only under a type checker.

Cause (respx 0.23.1)

Three guards hard-code httpx.Response:

  • respx/models.py:199: the return_value setter raises TypeError(f"{return_value!r} is not an instance of httpx.Response").
  • respx/router.py:265, 314, 319: the resolver and handlers assert isinstance(resolved.response, httpx.Response).

A side_effect element holding an httpx2.Response trips the assert. A return_value trips the setter.

Why loosening the guards looks cheap

Everything downstream reads the response structurally, and httpx2.Response exposes the same attributes:

  • clone_response() (models.py:34) reads .status_code, .headers, .stream, .extensions.
  • the httpcore conversion (mocks.py:322) reads .status_code, .headers.raw, .stream.

An httpx2.Response would pass through as a carrier of status, headers, and stream, the same role httpx.Response fills today. Accepting either type at the guards (or a structural check, to skip importing httpx2) covers the minimal case. respx can keep constructing httpx.Response internally; the caller-supplied object only needs to clear the guards.

What surprised me

Mocking httpx2 but feeding it httpx.Response reads backwards, and importing the legacy package in an httpx2-only test suite stands out. Neither the respx nor pytest-httpx2 docs mention the requirement.

Workaround for anyone hitting this

Return httpx.Response (or respx.MockResponse) for the response and keep httpx2.* for raised exceptions:

import httpx
respx.get(...).mock(side_effect=[httpx2.ReadTimeout("timed out"), httpx.Response(200, text="ok")])

I can send a PR for the minimal guard change if it would be welcome.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions