Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/add_per_request_headers_timeout_auth_overrides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
default: minor
---

# Add per-request headers, timeout, and auth overrides to endpoint functions

All generated endpoint functions (`sync_detailed`, `asyncio_detailed`, `sync`, `asyncio`) now accept three optional keyword arguments forwarded directly to the underlying httpx request:

- `headers: dict[str, str] | None = None` — extra headers merged on top of any spec-defined headers for this request
- `timeout: httpx.Timeout | None | Unset = UNSET` — override the client-level timeout for this request; `None` disables the timeout
- `auth: httpx.Auth | None | Unset = UNSET` — override the client-level auth for this request; `None` disables auth

This allows per-request customisation without creating a new client instance, preserving the shared httpx connection pool. Using `with_headers()` / `with_timeout()` at runtime was previously the only option, but those methods mutate the original client's underlying httpx client as a side effect and cause the returned client to open a new connection pool on first use—making them unsafe for concurrent async code.

Note: client-level headers cannot be fully removed for a single request via this mechanism, only overridden with a different value.
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
from unittest.mock import MagicMock

import httpx
import pytest

from end_to_end_tests.functional_tests.helpers import (
with_generated_client_fixture,
with_generated_code_import,
)


SIMPLE_SPEC = """
paths:
"/items":
get:
operationId: getItems
responses:
"200":
description: Success
"""

SPEC_WITH_HEADER_PARAM = """
paths:
"/items":
get:
operationId: getItems
parameters:
- name: X-Request-Id
in: header
required: true
schema:
type: string
responses:
"200":
description: Success
"""


def _make_mock_client(Client):
mock_httpx_client = MagicMock(spec=httpx.Client)
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.content = b""
mock_response.headers = {}
mock_httpx_client.request.return_value = mock_response
client = Client(base_url="https://api.example.com")
client.set_httpx_client(mock_httpx_client)
return client, mock_httpx_client


@with_generated_client_fixture(SIMPLE_SPEC)
@with_generated_code_import(".api.default.get_items.sync_detailed")
@with_generated_code_import(".client.Client")
class TestPerRequestHeaders:
def test_extra_headers_are_forwarded(self, sync_detailed, Client):
client, mock_httpx = _make_mock_client(Client)
sync_detailed(client=client, headers={"X-Trace-Id": "abc123"})
call_kwargs = mock_httpx.request.call_args[1]
assert call_kwargs["headers"]["X-Trace-Id"] == "abc123"

def test_omitting_headers_does_not_inject_headers_key(self, sync_detailed, Client):
client, mock_httpx = _make_mock_client(Client)
sync_detailed(client=client)
call_kwargs = mock_httpx.request.call_args[1]
# No spec-defined headers and no override — headers key should be absent
assert "headers" not in call_kwargs

def test_multiple_extra_headers_are_all_forwarded(self, sync_detailed, Client):
client, mock_httpx = _make_mock_client(Client)
sync_detailed(client=client, headers={"X-A": "1", "X-B": "2"})
call_kwargs = mock_httpx.request.call_args[1]
assert call_kwargs["headers"]["X-A"] == "1"
assert call_kwargs["headers"]["X-B"] == "2"


@with_generated_client_fixture(SPEC_WITH_HEADER_PARAM)
@with_generated_code_import(".api.default.get_items.sync_detailed")
@with_generated_code_import(".client.Client")
class TestPerRequestHeadersWithSpecHeaders:
def test_caller_header_overrides_spec_header(self, sync_detailed, Client):
client, mock_httpx = _make_mock_client(Client)
sync_detailed(client=client, x_request_id="spec-value", headers={"X-Request-Id": "override"})
call_kwargs = mock_httpx.request.call_args[1]
assert call_kwargs["headers"]["X-Request-Id"] == "override"

def test_caller_headers_merged_with_spec_headers(self, sync_detailed, Client):
client, mock_httpx = _make_mock_client(Client)
sync_detailed(client=client, x_request_id="spec-value", headers={"X-Extra": "extra"})
call_kwargs = mock_httpx.request.call_args[1]
assert call_kwargs["headers"]["X-Request-Id"] == "spec-value"
assert call_kwargs["headers"]["X-Extra"] == "extra"


@with_generated_client_fixture(SIMPLE_SPEC)
@with_generated_code_import(".api.default.get_items.sync_detailed")
@with_generated_code_import(".client.Client")
class TestPerRequestTimeout:
def test_timeout_override_is_forwarded(self, sync_detailed, Client):
client, mock_httpx = _make_mock_client(Client)
sync_detailed(client=client, timeout=httpx.Timeout(120.0))
call_kwargs = mock_httpx.request.call_args[1]
assert call_kwargs["timeout"] == httpx.Timeout(120.0)

def test_timeout_none_disables_timeout(self, sync_detailed, Client):
client, mock_httpx = _make_mock_client(Client)
sync_detailed(client=client, timeout=None)
call_kwargs = mock_httpx.request.call_args[1]
assert call_kwargs["timeout"] is None

def test_omitting_timeout_does_not_inject_timeout_key(self, sync_detailed, Client):
client, mock_httpx = _make_mock_client(Client)
sync_detailed(client=client)
call_kwargs = mock_httpx.request.call_args[1]
assert "timeout" not in call_kwargs


@with_generated_client_fixture(SIMPLE_SPEC)
@with_generated_code_import(".api.default.get_items.sync_detailed")
@with_generated_code_import(".client.Client")
class TestPerRequestAuth:
def test_auth_override_is_forwarded(self, sync_detailed, Client):
client, mock_httpx = _make_mock_client(Client)
auth = httpx.BasicAuth("user", "pass")
sync_detailed(client=client, auth=auth)
call_kwargs = mock_httpx.request.call_args[1]
assert call_kwargs["auth"] is auth

def test_auth_none_disables_auth(self, sync_detailed, Client):
client, mock_httpx = _make_mock_client(Client)
sync_detailed(client=client, auth=None)
call_kwargs = mock_httpx.request.call_args[1]
assert call_kwargs["auth"] is None

def test_omitting_auth_does_not_inject_auth_key(self, sync_detailed, Client):
client, mock_httpx = _make_mock_client(Client)
sync_detailed(client=client)
call_kwargs = mock_httpx.request.call_args[1]
assert "auth" not in call_kwargs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ def sync_detailed(
*,
client: AuthenticatedClient | Client,
body: JsonLikeBody | Unset = UNSET,
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None | Unset = UNSET,
auth: httpx.Auth | None | Unset = UNSET,
) -> Response[Any]:
"""A content type that works like json but isn't application/json

Expand All @@ -69,6 +72,12 @@ def sync_detailed(
kwargs = _get_kwargs(
body=body,
)
if headers is not None:
kwargs["headers"] = {**kwargs.get("headers", {}), **headers}
if not isinstance(timeout, Unset):
kwargs["timeout"] = timeout
if not isinstance(auth, Unset):
kwargs["auth"] = auth

response = client.get_httpx_client().request(
**kwargs,
Expand All @@ -81,6 +90,9 @@ async def asyncio_detailed(
*,
client: AuthenticatedClient | Client,
body: JsonLikeBody | Unset = UNSET,
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None | Unset = UNSET,
auth: httpx.Auth | None | Unset = UNSET,
) -> Response[Any]:
"""A content type that works like json but isn't application/json

Expand All @@ -98,6 +110,12 @@ async def asyncio_detailed(
kwargs = _get_kwargs(
body=body,
)
if headers is not None:
kwargs["headers"] = {**kwargs.get("headers", {}), **headers}
if not isinstance(timeout, Unset):
kwargs["timeout"] = timeout
if not isinstance(auth, Unset):
kwargs["auth"] = auth

response = await client.get_async_httpx_client().request(**kwargs)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ def sync_detailed(
*,
client: AuthenticatedClient | Client,
body: OptionalBodyBody | Unset = UNSET,
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None | Unset = UNSET,
auth: httpx.Auth | None | Unset = UNSET,
) -> Response[Any]:
"""Test optional request body

Expand All @@ -69,6 +72,12 @@ def sync_detailed(
kwargs = _get_kwargs(
body=body,
)
if headers is not None:
kwargs["headers"] = {**kwargs.get("headers", {}), **headers}
if not isinstance(timeout, Unset):
kwargs["timeout"] = timeout
if not isinstance(auth, Unset):
kwargs["auth"] = auth

response = client.get_httpx_client().request(
**kwargs,
Expand All @@ -81,6 +90,9 @@ async def asyncio_detailed(
*,
client: AuthenticatedClient | Client,
body: OptionalBodyBody | Unset = UNSET,
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None | Unset = UNSET,
auth: httpx.Auth | None | Unset = UNSET,
) -> Response[Any]:
"""Test optional request body

Expand All @@ -98,6 +110,12 @@ async def asyncio_detailed(
kwargs = _get_kwargs(
body=body,
)
if headers is not None:
kwargs["headers"] = {**kwargs.get("headers", {}), **headers}
if not isinstance(timeout, Unset):
kwargs["timeout"] = timeout
if not isinstance(auth, Unset):
kwargs["auth"] = auth

response = await client.get_async_httpx_client().request(**kwargs)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ def sync_detailed(
*,
client: AuthenticatedClient | Client,
body: PostBodiesMultipleJsonBody | File | PostBodiesMultipleDataBody | PostBodiesMultipleFilesBody | Unset = UNSET,
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None | Unset = UNSET,
auth: httpx.Auth | None | Unset = UNSET,
) -> Response[Any]:
"""Test multiple bodies

Expand All @@ -90,6 +93,12 @@ def sync_detailed(
kwargs = _get_kwargs(
body=body,
)
if headers is not None:
kwargs["headers"] = {**kwargs.get("headers", {}), **headers}
if not isinstance(timeout, Unset):
kwargs["timeout"] = timeout
if not isinstance(auth, Unset):
kwargs["auth"] = auth

response = client.get_httpx_client().request(
**kwargs,
Expand All @@ -102,6 +111,9 @@ async def asyncio_detailed(
*,
client: AuthenticatedClient | Client,
body: PostBodiesMultipleJsonBody | File | PostBodiesMultipleDataBody | PostBodiesMultipleFilesBody | Unset = UNSET,
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None | Unset = UNSET,
auth: httpx.Auth | None | Unset = UNSET,
) -> Response[Any]:
"""Test multiple bodies

Expand All @@ -122,6 +134,12 @@ async def asyncio_detailed(
kwargs = _get_kwargs(
body=body,
)
if headers is not None:
kwargs["headers"] = {**kwargs.get("headers", {}), **headers}
if not isinstance(timeout, Unset):
kwargs["timeout"] = timeout
if not isinstance(auth, Unset):
kwargs["auth"] = auth

response = await client.get_async_httpx_client().request(**kwargs)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ def sync_detailed(
*,
client: AuthenticatedClient | Client,
body: AModel | Unset = UNSET,
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None | Unset = UNSET,
auth: httpx.Auth | None | Unset = UNSET,
) -> Response[Any]:
"""Test request body defined via ref

Expand All @@ -69,6 +72,12 @@ def sync_detailed(
kwargs = _get_kwargs(
body=body,
)
if headers is not None:
kwargs["headers"] = {**kwargs.get("headers", {}), **headers}
if not isinstance(timeout, Unset):
kwargs["timeout"] = timeout
if not isinstance(auth, Unset):
kwargs["auth"] = auth

response = client.get_httpx_client().request(
**kwargs,
Expand All @@ -81,6 +90,9 @@ async def asyncio_detailed(
*,
client: AuthenticatedClient | Client,
body: AModel | Unset = UNSET,
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None | Unset = UNSET,
auth: httpx.Auth | None | Unset = UNSET,
) -> Response[Any]:
"""Test request body defined via ref

Expand All @@ -98,6 +110,12 @@ async def asyncio_detailed(
kwargs = _get_kwargs(
body=body,
)
if headers is not None:
kwargs["headers"] = {**kwargs.get("headers", {}), **headers}
if not isinstance(timeout, Unset):
kwargs["timeout"] = timeout
if not isinstance(auth, Unset):
kwargs["auth"] = auth

response = await client.get_async_httpx_client().request(**kwargs)

Expand Down
Loading