From 4f2351168d2e3eb363548eaed50c96d0aac0cdf8 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:05:14 +0100 Subject: [PATCH 01/11] fix: make OpenAI errors pickleable --- src/openai/_exceptions.py | 17 +++++++-- tests/test_exceptions_pickle.py | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 tests/test_exceptions_pickle.py diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index 7a30e4a336..bd9b57e176 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Optional, cast -from typing_extensions import Literal +from typing_extensions import Literal, override import httpx2 @@ -32,7 +32,20 @@ class OpenAIError(Exception): - pass + @override + def __reduce__(self) -> tuple[object, tuple[object, ...]]: + return (_reconstruct_openai_error, (type(self), self.args, self.__dict__)) + + +def _reconstruct_openai_error( + error_type: type[OpenAIError], + args: tuple[object, ...], + state: dict[str, Any], +) -> OpenAIError: + error = error_type.__new__(error_type) + Exception.__init__(error, *args) + error.__dict__.update(state) + return error class SubjectTokenProviderError(OpenAIError): diff --git a/tests/test_exceptions_pickle.py b/tests/test_exceptions_pickle.py new file mode 100644 index 0000000000..f4a072da99 --- /dev/null +++ b/tests/test_exceptions_pickle.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pickle + +import httpx2 + +from openai import APITimeoutError, BadRequestError +from openai._exceptions import WebSocketConnectionClosedError + + +def test_timeout_error_pickle_round_trip() -> None: + request = httpx2.Request("POST", "https://example.test/v1/responses") + error = APITimeoutError(request) + + restored = pickle.loads(pickle.dumps(error)) + + assert isinstance(restored, APITimeoutError) + assert str(restored) == "Request timed out." + assert restored.message == "Request timed out." + assert restored.request.method == "POST" + assert str(restored.request.url) == "https://example.test/v1/responses" + assert restored.body is None + + +def test_status_error_pickle_round_trip_preserves_response_state() -> None: + request = httpx2.Request("POST", "https://example.test/v1/responses") + response = httpx2.Response( + 400, + request=request, + headers={"x-request-id": "req_123"}, + json={"error": "bad request"}, + ) + body = { + "code": "invalid_value", + "param": "input", + "type": "invalid_request_error", + } + error = BadRequestError("Bad request", response=response, body=body) + + restored = pickle.loads(pickle.dumps(error)) + + assert isinstance(restored, BadRequestError) + assert restored.status_code == 400 + assert restored.request_id == "req_123" + assert restored.body == body + assert restored.code == "invalid_value" + assert restored.param == "input" + assert restored.type == "invalid_request_error" + assert restored.response.status_code == 400 + assert restored.response.json() == {"error": "bad request"} + + +def test_websocket_error_pickle_round_trip_preserves_unsent_messages() -> None: + error = WebSocketConnectionClosedError( + "connection closed", + unsent_messages=["first", "second"], + ) + + restored = pickle.loads(pickle.dumps(error)) + + assert isinstance(restored, WebSocketConnectionClosedError) + assert str(restored) == "connection closed" + assert restored.unsent_messages == ["first", "second"] From abc541df8b75ea456b89f378911d20a350d14e88 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:08:47 +0100 Subject: [PATCH 02/11] fix: snapshot transport state when pickling errors --- src/openai/_exceptions.py | 79 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index bd9b57e176..2cadf324a3 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -30,21 +30,98 @@ "WebSocketQueueFullError", ] +_RequestSnapshot = tuple[str, str, list[tuple[bytes, bytes]], bytes | None] +_ResponseSnapshot = tuple[int, list[tuple[bytes, bytes]], bytes | None, _RequestSnapshot | None] + class OpenAIError(Exception): @override def __reduce__(self) -> tuple[object, tuple[object, ...]]: - return (_reconstruct_openai_error, (type(self), self.args, self.__dict__)) + state = self.__dict__.copy() + + request_snapshot: _RequestSnapshot | None = None + request = state.get("request") + if isinstance(request, httpx2.Request): + request_snapshot = _snapshot_request(request) + del state["request"] + + response_snapshot: _ResponseSnapshot | None = None + response = state.get("response") + if isinstance(response, httpx2.Response): + response_snapshot = _snapshot_response(response) + del state["response"] + + return ( + _reconstruct_openai_error, + (type(self), self.args, state, request_snapshot, response_snapshot), + ) + + +def _snapshot_request(request: httpx2.Request) -> _RequestSnapshot: + try: + content = request.content + except httpx2.RequestNotRead: + content = None + + return (request.method, str(request.url), list(request.headers.raw), content) + + +def _restore_request(snapshot: _RequestSnapshot) -> httpx2.Request: + method, url, headers, content = snapshot + if content is None: + return httpx2.Request(method, url, headers=headers) + return httpx2.Request(method, url, headers=headers, content=content) + + +def _snapshot_response(response: httpx2.Response) -> _ResponseSnapshot: + try: + content = response.content + except httpx2.ResponseNotRead: + content = None + + try: + request_snapshot = _snapshot_request(response.request) + except RuntimeError: + request_snapshot = None + + return (response.status_code, list(response.headers.raw), content, request_snapshot) + + +def _restore_response( + snapshot: _ResponseSnapshot, + *, + request: httpx2.Request | None, +) -> httpx2.Response: + status_code, headers, content, response_request_snapshot = snapshot + if request is None and response_request_snapshot is not None: + request = _restore_request(response_request_snapshot) + + kwargs: dict[str, Any] = {"headers": headers} + if content is not None: + kwargs["content"] = content + if request is not None: + kwargs["request"] = request + + return httpx2.Response(status_code, **kwargs) def _reconstruct_openai_error( error_type: type[OpenAIError], args: tuple[object, ...], state: dict[str, Any], + request_snapshot: _RequestSnapshot | None, + response_snapshot: _ResponseSnapshot | None, ) -> OpenAIError: error = error_type.__new__(error_type) Exception.__init__(error, *args) error.__dict__.update(state) + + request = _restore_request(request_snapshot) if request_snapshot is not None else None + if request is not None: + error.__dict__["request"] = request + if response_snapshot is not None: + error.__dict__["response"] = _restore_response(response_snapshot, request=request) + return error From a941f9978f9161f9bd7c772a59929d7a7773cc4c Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:09:16 +0100 Subject: [PATCH 03/11] test: cover pickling transport-backed errors --- tests/test_exceptions_pickle.py | 77 ++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/tests/test_exceptions_pickle.py b/tests/test_exceptions_pickle.py index f4a072da99..1eedc15a40 100644 --- a/tests/test_exceptions_pickle.py +++ b/tests/test_exceptions_pickle.py @@ -1,13 +1,49 @@ from __future__ import annotations +import asyncio import pickle +from collections.abc import Iterator +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread import httpx2 +import pytest -from openai import APITimeoutError, BadRequestError +from openai import APITimeoutError, AsyncOpenAI, BadRequestError, OpenAI from openai._exceptions import WebSocketConnectionClosedError +_ERROR_PAYLOAD = b'{"error":{"message":"bad request","type":"invalid_request_error","param":null,"code":"bad_request"}}' + + +class _BadRequestHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: + self.send_response(400) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(_ERROR_PAYLOAD))) + self.send_header("x-request-id", "req_transport") + self.end_headers() + self.wfile.write(_ERROR_PAYLOAD) + + def log_message(self, format: str, *args: object) -> None: + pass + + +@pytest.fixture +def transport_error_base_url() -> Iterator[str]: + server = ThreadingHTTPServer(("127.0.0.1", 0), _BadRequestHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}/v1" + finally: + server.shutdown() + thread.join() + server.server_close() + + def test_timeout_error_pickle_round_trip() -> None: request = httpx2.Request("POST", "https://example.test/v1/responses") error = APITimeoutError(request) @@ -50,6 +86,45 @@ def test_status_error_pickle_round_trip_preserves_response_state() -> None: assert restored.response.json() == {"error": "bad request"} +def _assert_transport_status_error_pickle_round_trip(error: BadRequestError) -> None: + restored = pickle.loads(pickle.dumps(error)) + + assert isinstance(restored, BadRequestError) + assert restored.status_code == 400 + assert restored.request_id == "req_transport" + assert restored.response.status_code == 400 + assert restored.response.request is restored.request + assert restored.request.method == "GET" + assert str(restored.request.url).endswith("/v1/models") + assert restored.response.json() == { + "error": { + "message": "bad request", + "type": "invalid_request_error", + "param": None, + "code": "bad_request", + } + } + + +def test_status_error_from_sync_transport_pickle_round_trip(transport_error_base_url: str) -> None: + with OpenAI(api_key="test", base_url=transport_error_base_url) as client: + with pytest.raises(BadRequestError) as exc_info: + client.models.list() + + _assert_transport_status_error_pickle_round_trip(exc_info.value) + + +def test_status_error_from_async_transport_pickle_round_trip(transport_error_base_url: str) -> None: + async def run() -> None: + async with AsyncOpenAI(api_key="test", base_url=transport_error_base_url) as client: + with pytest.raises(BadRequestError) as exc_info: + await client.models.list() + + _assert_transport_status_error_pickle_round_trip(exc_info.value) + + asyncio.run(run()) + + def test_websocket_error_pickle_round_trip_preserves_unsent_messages() -> None: error = WebSocketConnectionClosedError( "connection closed", From 3a5ec231855705493520ec7bca58d8d935a34258 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:16:13 +0100 Subject: [PATCH 04/11] fix: bypass subclass __new__ when unpickling errors --- src/openai/_exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index 2cadf324a3..912b703780 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -112,7 +112,7 @@ def _reconstruct_openai_error( request_snapshot: _RequestSnapshot | None, response_snapshot: _ResponseSnapshot | None, ) -> OpenAIError: - error = error_type.__new__(error_type) + error = Exception.__new__(error_type) Exception.__init__(error, *args) error.__dict__.update(state) From 65e4756fd759f75056944b1325b589d3ebaf67c9 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:16:34 +0100 Subject: [PATCH 05/11] test: cover custom exception allocation during unpickle --- tests/test_exceptions_pickle.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_exceptions_pickle.py b/tests/test_exceptions_pickle.py index 1eedc15a40..40be8114bd 100644 --- a/tests/test_exceptions_pickle.py +++ b/tests/test_exceptions_pickle.py @@ -9,13 +9,18 @@ import httpx2 import pytest -from openai import APITimeoutError, AsyncOpenAI, BadRequestError, OpenAI +from openai import APITimeoutError, AsyncOpenAI, BadRequestError, OpenAI, OpenAIError from openai._exceptions import WebSocketConnectionClosedError _ERROR_PAYLOAD = b'{"error":{"message":"bad request","type":"invalid_request_error","param":null,"code":"bad_request"}}' +class _CustomNewOpenAIError(OpenAIError): + def __new__(cls, message: str) -> _CustomNewOpenAIError: + return Exception.__new__(cls) + + class _BadRequestHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -125,6 +130,15 @@ async def run() -> None: asyncio.run(run()) +def test_custom_error_subclass_with_required_new_argument_pickle_round_trip() -> None: + error = _CustomNewOpenAIError("custom error") + + restored = pickle.loads(pickle.dumps(error)) + + assert isinstance(restored, _CustomNewOpenAIError) + assert str(restored) == "custom error" + + def test_websocket_error_pickle_round_trip_preserves_unsent_messages() -> None: error = WebSocketConnectionClosedError( "connection closed", From 02cc689325386fb3e8b06d245707123a300f0f69 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:44:24 +0100 Subject: [PATCH 06/11] fix: preserve safe exception snapshot state --- src/openai/_exceptions.py | 62 +++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index 912b703780..21d3b75324 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import timedelta from typing import TYPE_CHECKING, Any, Optional, cast from typing_extensions import Literal, override @@ -31,13 +32,23 @@ ] _RequestSnapshot = tuple[str, str, list[tuple[bytes, bytes]], bytes | None] -_ResponseSnapshot = tuple[int, list[tuple[bytes, bytes]], bytes | None, _RequestSnapshot | None] +_ResponseSnapshot = tuple[ + int, + list[tuple[bytes, bytes]], + bytes | None, + _RequestSnapshot | None, + timedelta | None, +] + +_REDACTED_HEADER_VALUE = b"" +_SENSITIVE_REQUEST_HEADERS = {b"api-key", b"authorization", b"x-amz-security-token"} class OpenAIError(Exception): @override def __reduce__(self) -> tuple[object, tuple[object, ...]]: state = self.__dict__.copy() + slot_state = _snapshot_slot_state(self) request_snapshot: _RequestSnapshot | None = None request = state.get("request") @@ -53,17 +64,24 @@ def __reduce__(self) -> tuple[object, tuple[object, ...]]: return ( _reconstruct_openai_error, - (type(self), self.args, state, request_snapshot, response_snapshot), + (type(self), self.args, state, slot_state, request_snapshot, response_snapshot), ) +def _snapshot_headers(headers: httpx2.Headers) -> list[tuple[bytes, bytes]]: + return [ + (name, _REDACTED_HEADER_VALUE if name.lower() in _SENSITIVE_REQUEST_HEADERS else value) + for name, value in headers.raw + ] + + def _snapshot_request(request: httpx2.Request) -> _RequestSnapshot: try: content = request.content except httpx2.RequestNotRead: content = None - return (request.method, str(request.url), list(request.headers.raw), content) + return (request.method, str(request.url), _snapshot_headers(request.headers), content) def _restore_request(snapshot: _RequestSnapshot) -> httpx2.Request: @@ -84,7 +102,12 @@ def _snapshot_response(response: httpx2.Response) -> _ResponseSnapshot: except RuntimeError: request_snapshot = None - return (response.status_code, list(response.headers.raw), content, request_snapshot) + try: + elapsed = response.elapsed + except RuntimeError: + elapsed = None + + return (response.status_code, list(response.headers.raw), content, request_snapshot, elapsed) def _restore_response( @@ -92,7 +115,7 @@ def _restore_response( *, request: httpx2.Request | None, ) -> httpx2.Response: - status_code, headers, content, response_request_snapshot = snapshot + status_code, headers, content, response_request_snapshot, elapsed = snapshot if request is None and response_request_snapshot is not None: request = _restore_request(response_request_snapshot) @@ -102,19 +125,46 @@ def _restore_response( if request is not None: kwargs["request"] = request - return httpx2.Response(status_code, **kwargs) + response = httpx2.Response(status_code, **kwargs) + if elapsed is not None: + response.elapsed = elapsed + return response + + +def _snapshot_slot_state(error: OpenAIError) -> dict[str, Any]: + slot_state: dict[str, Any] = {} + for cls in type(error).__mro__: + slots = cls.__dict__.get("__slots__") + if slots is None: + continue + if isinstance(slots, str): + slots = (slots,) + for slot in slots: + if slot in {"__dict__", "__weakref__"}: + continue + name = slot + if name.startswith("__") and not name.endswith("__"): + name = f"_{cls.__name__.lstrip('_')}{name}" + try: + slot_state[name] = getattr(error, name) + except AttributeError: + pass + return slot_state def _reconstruct_openai_error( error_type: type[OpenAIError], args: tuple[object, ...], state: dict[str, Any], + slot_state: dict[str, Any], request_snapshot: _RequestSnapshot | None, response_snapshot: _ResponseSnapshot | None, ) -> OpenAIError: error = Exception.__new__(error_type) Exception.__init__(error, *args) error.__dict__.update(state) + for name, value in slot_state.items(): + setattr(error, name, value) request = _restore_request(request_snapshot) if request_snapshot is not None else None if request is not None: From 34b272a138ed206e8bf1d5003b04273b6651e114 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:44:50 +0100 Subject: [PATCH 07/11] test: cover secure pickle snapshot state --- tests/test_exceptions_pickle.py | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_exceptions_pickle.py b/tests/test_exceptions_pickle.py index 40be8114bd..ffe81e7fc4 100644 --- a/tests/test_exceptions_pickle.py +++ b/tests/test_exceptions_pickle.py @@ -2,6 +2,7 @@ import asyncio import pickle +from datetime import timedelta from collections.abc import Iterator from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread @@ -21,6 +22,14 @@ def __new__(cls, message: str) -> _CustomNewOpenAIError: return Exception.__new__(cls) +class _SlottedOpenAIError(OpenAIError): + __slots__ = ("context",) + + def __init__(self, message: str, context: str) -> None: + super().__init__(message, context) + self.context = context + + class _BadRequestHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -63,6 +72,28 @@ def test_timeout_error_pickle_round_trip() -> None: assert restored.body is None +@pytest.mark.parametrize( + ("header_name", "header_value"), + [ + ("Authorization", "Bearer sk-openai-secret"), + ("api-key", "azure-secret-key"), + ], +) +def test_request_credentials_are_redacted_from_pickle(header_name: str, header_value: str) -> None: + request = httpx2.Request( + "POST", + "https://example.test/v1/responses", + headers={header_name: header_value}, + ) + error = APITimeoutError(request) + + payload = pickle.dumps(error) + restored = pickle.loads(payload) + + assert header_value.encode() not in payload + assert restored.request.headers[header_name] == "" + + def test_status_error_pickle_round_trip_preserves_response_state() -> None: request = httpx2.Request("POST", "https://example.test/v1/responses") response = httpx2.Response( @@ -71,6 +102,7 @@ def test_status_error_pickle_round_trip_preserves_response_state() -> None: headers={"x-request-id": "req_123"}, json={"error": "bad request"}, ) + response.elapsed = timedelta(milliseconds=125) body = { "code": "invalid_value", "param": "input", @@ -89,6 +121,7 @@ def test_status_error_pickle_round_trip_preserves_response_state() -> None: assert restored.type == "invalid_request_error" assert restored.response.status_code == 400 assert restored.response.json() == {"error": "bad request"} + assert restored.response.elapsed == timedelta(milliseconds=125) def _assert_transport_status_error_pickle_round_trip(error: BadRequestError) -> None: @@ -109,6 +142,9 @@ def _assert_transport_status_error_pickle_round_trip(error: BadRequestError) -> "code": "bad_request", } } + assert restored.request.headers["Authorization"] == "" + assert error.request.headers["Authorization"] != restored.request.headers["Authorization"] + assert restored.response.elapsed >= timedelta(0) def test_status_error_from_sync_transport_pickle_round_trip(transport_error_base_url: str) -> None: @@ -139,6 +175,16 @@ def test_custom_error_subclass_with_required_new_argument_pickle_round_trip() -> assert str(restored) == "custom error" +def test_custom_error_subclass_preserves_slotted_state() -> None: + error = _SlottedOpenAIError("custom error", "slot context") + + restored = pickle.loads(pickle.dumps(error)) + + assert isinstance(restored, _SlottedOpenAIError) + assert restored.args == ("custom error", "slot context") + assert restored.context == "slot context" + + def test_websocket_error_pickle_round_trip_preserves_unsent_messages() -> None: error = WebSocketConnectionClosedError( "connection closed", From 470fc2f5eb52f07939112c79afc8a79d653bdd84 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:45:38 +0100 Subject: [PATCH 08/11] style: sort pickle test imports --- tests/test_exceptions_pickle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_exceptions_pickle.py b/tests/test_exceptions_pickle.py index ffe81e7fc4..e1089af752 100644 --- a/tests/test_exceptions_pickle.py +++ b/tests/test_exceptions_pickle.py @@ -2,8 +2,8 @@ import asyncio import pickle -from datetime import timedelta from collections.abc import Iterator +from datetime import timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread From 75ee9cdf086a6ca70cad024a7b62e2ab870128e3 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:21:22 +0100 Subject: [PATCH 09/11] fix: harden exception pickle snapshots --- src/openai/_exceptions.py | 96 +++++++++++++++++++++++---------- tests/test_exceptions_pickle.py | 72 +++++++++++++++++++++++-- tests/test_httpx_compat.py | 58 ++++++++++++++++++-- 3 files changed, 191 insertions(+), 35 deletions(-) diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index 21d3b75324..4c0baee710 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from datetime import timedelta from typing import TYPE_CHECKING, Any, Optional, cast from typing_extensions import Literal, override @@ -31,17 +32,29 @@ "WebSocketQueueFullError", ] -_RequestSnapshot = tuple[str, str, list[tuple[bytes, bytes]], bytes | None] +_RequestTimeoutSnapshot = dict[str, float | None] +_RequestSnapshot = tuple[str, list[tuple[bytes, bytes]], _RequestTimeoutSnapshot | None] _ResponseSnapshot = tuple[ int, list[tuple[bytes, bytes]], - bytes | None, _RequestSnapshot | None, timedelta | None, ] +_REDACTED_REQUEST_URL = "https://redacted.invalid/" _REDACTED_HEADER_VALUE = b"" -_SENSITIVE_REQUEST_HEADERS = {b"api-key", b"authorization", b"x-amz-security-token"} +_SENSITIVE_HEADERS = { + b"api-key", + b"authorization", + b"cookie", + b"host", + b"proxy-authorization", + b"set-cookie", + b"x-amz-security-token", + b"x-api-key", + b"x-goog-api-key", +} +_TIMEOUT_EXTENSION_KEYS = ("connect", "read", "write", "pool") class OpenAIError(Exception): @@ -52,13 +65,13 @@ def __reduce__(self) -> tuple[object, tuple[object, ...]]: request_snapshot: _RequestSnapshot | None = None request = state.get("request") - if isinstance(request, httpx2.Request): + if _is_http_request(request): request_snapshot = _snapshot_request(request) del state["request"] response_snapshot: _ResponseSnapshot | None = None response = state.get("response") - if isinstance(response, httpx2.Response): + if _is_http_response(response): response_snapshot = _snapshot_response(response) del state["response"] @@ -68,35 +81,61 @@ def __reduce__(self) -> tuple[object, tuple[object, ...]]: ) -def _snapshot_headers(headers: httpx2.Headers) -> list[tuple[bytes, bytes]]: +def _is_legacy_httpx_instance(value: object, type_name: str) -> bool: + module = sys.modules.get("httpx") + legacy_type = getattr(module, type_name, None) if module is not None else None + return isinstance(legacy_type, type) and isinstance(value, legacy_type) + + +def _is_http_request(value: object) -> bool: + return isinstance(value, httpx2.Request) or _is_legacy_httpx_instance(value, "Request") + + +def _is_http_response(value: object) -> bool: + return isinstance(value, httpx2.Response) or _is_legacy_httpx_instance(value, "Response") + + +def _snapshot_headers(headers: Any) -> list[tuple[bytes, bytes]]: return [ - (name, _REDACTED_HEADER_VALUE if name.lower() in _SENSITIVE_REQUEST_HEADERS else value) + (name, _REDACTED_HEADER_VALUE if name.lower() in _SENSITIVE_HEADERS else value) for name, value in headers.raw ] -def _snapshot_request(request: httpx2.Request) -> _RequestSnapshot: - try: - content = request.content - except httpx2.RequestNotRead: - content = None +def _snapshot_timeout_extension(request: Any) -> _RequestTimeoutSnapshot | None: + extensions = request.extensions + timeout = extensions.get("timeout") if isinstance(extensions, dict) else None + if not isinstance(timeout, dict): + return None - return (request.method, str(request.url), _snapshot_headers(request.headers), content) + snapshot: _RequestTimeoutSnapshot = {} + for key in _TIMEOUT_EXTENSION_KEYS: + value = timeout.get(key) + if value is None: + if key in timeout: + snapshot[key] = None + elif isinstance(value, (int, float)): + snapshot[key] = float(value) + return snapshot or None -def _restore_request(snapshot: _RequestSnapshot) -> httpx2.Request: - method, url, headers, content = snapshot - if content is None: - return httpx2.Request(method, url, headers=headers) - return httpx2.Request(method, url, headers=headers, content=content) +def _snapshot_request(request: Any) -> _RequestSnapshot: + return ( + request.method, + _snapshot_headers(request.headers), + _snapshot_timeout_extension(request), + ) -def _snapshot_response(response: httpx2.Response) -> _ResponseSnapshot: - try: - content = response.content - except httpx2.ResponseNotRead: - content = None +def _restore_request(snapshot: _RequestSnapshot) -> httpx2.Request: + method, headers, timeout = snapshot + kwargs: dict[str, Any] = {"headers": headers} + if timeout is not None: + kwargs["extensions"] = {"timeout": timeout} + return httpx2.Request(method, _REDACTED_REQUEST_URL, **kwargs) + +def _snapshot_response(response: Any) -> _ResponseSnapshot: try: request_snapshot = _snapshot_request(response.request) except RuntimeError: @@ -107,7 +146,12 @@ def _snapshot_response(response: httpx2.Response) -> _ResponseSnapshot: except RuntimeError: elapsed = None - return (response.status_code, list(response.headers.raw), content, request_snapshot, elapsed) + return ( + response.status_code, + _snapshot_headers(response.headers), + request_snapshot, + elapsed, + ) def _restore_response( @@ -115,13 +159,11 @@ def _restore_response( *, request: httpx2.Request | None, ) -> httpx2.Response: - status_code, headers, content, response_request_snapshot, elapsed = snapshot + status_code, headers, response_request_snapshot, elapsed = snapshot if request is None and response_request_snapshot is not None: request = _restore_request(response_request_snapshot) kwargs: dict[str, Any] = {"headers": headers} - if content is not None: - kwargs["content"] = content if request is not None: kwargs["request"] = request diff --git a/tests/test_exceptions_pickle.py b/tests/test_exceptions_pickle.py index e1089af752..77ef7a5930 100644 --- a/tests/test_exceptions_pickle.py +++ b/tests/test_exceptions_pickle.py @@ -11,10 +11,11 @@ import pytest from openai import APITimeoutError, AsyncOpenAI, BadRequestError, OpenAI, OpenAIError -from openai._exceptions import WebSocketConnectionClosedError +from openai._exceptions import SubjectTokenProviderError, WebSocketConnectionClosedError _ERROR_PAYLOAD = b'{"error":{"message":"bad request","type":"invalid_request_error","param":null,"code":"bad_request"}}' +_REDACTED_REQUEST_URL = "https://redacted.invalid/" class _CustomNewOpenAIError(OpenAIError): @@ -68,10 +69,40 @@ def test_timeout_error_pickle_round_trip() -> None: assert str(restored) == "Request timed out." assert restored.message == "Request timed out." assert restored.request.method == "POST" - assert str(restored.request.url) == "https://example.test/v1/responses" + assert str(restored.request.url) == _REDACTED_REQUEST_URL assert restored.body is None +def test_request_private_data_is_omitted_and_timeout_is_preserved() -> None: + request = httpx2.Request( + "POST", + "https://example.test/v1/responses?customer_secret=query-private", + content=b"request-body-private", + extensions={ + "timeout": { + "connect": 1.0, + "read": 2.0, + "write": 3.0, + "pool": 4.0, + } + }, + ) + error = APITimeoutError(request) + + payload = pickle.dumps(error) + restored = pickle.loads(payload) + + assert b"query-private" not in payload + assert b"request-body-private" not in payload + assert str(restored.request.url) == _REDACTED_REQUEST_URL + assert restored.request.extensions["timeout"] == { + "connect": 1.0, + "read": 2.0, + "write": 3.0, + "pool": 4.0, + } + + @pytest.mark.parametrize( ("header_name", "header_value"), [ @@ -94,6 +125,36 @@ def test_request_credentials_are_redacted_from_pickle(header_name: str, header_v assert restored.request.headers[header_name] == "" +def test_response_private_data_is_omitted_and_headers_are_redacted() -> None: + request = httpx2.Request("POST", "https://example.test/v1/token") + response = httpx2.Response( + 400, + request=request, + headers={ + "Authorization": "Bearer response-auth-secret", + "api-key": "response-api-key-secret", + "Set-Cookie": "session=response-cookie-secret", + "x-request-id": "req_123", + }, + content=b"response-body-private", + ) + error = SubjectTokenProviderError("token exchange failed", response=response) + + payload = pickle.dumps(error) + restored = pickle.loads(payload) + + assert b"response-auth-secret" not in payload + assert b"response-api-key-secret" not in payload + assert b"response-cookie-secret" not in payload + assert b"response-body-private" not in payload + assert restored.response is not None + assert restored.response.headers["Authorization"] == "" + assert restored.response.headers["api-key"] == "" + assert restored.response.headers["Set-Cookie"] == "" + assert restored.response.headers["x-request-id"] == "req_123" + assert restored.response.content == b"" + + def test_status_error_pickle_round_trip_preserves_response_state() -> None: request = httpx2.Request("POST", "https://example.test/v1/responses") response = httpx2.Response( @@ -120,7 +181,7 @@ def test_status_error_pickle_round_trip_preserves_response_state() -> None: assert restored.param == "input" assert restored.type == "invalid_request_error" assert restored.response.status_code == 400 - assert restored.response.json() == {"error": "bad request"} + assert restored.response.content == b"" assert restored.response.elapsed == timedelta(milliseconds=125) @@ -133,8 +194,8 @@ def _assert_transport_status_error_pickle_round_trip(error: BadRequestError) -> assert restored.response.status_code == 400 assert restored.response.request is restored.request assert restored.request.method == "GET" - assert str(restored.request.url).endswith("/v1/models") - assert restored.response.json() == { + assert str(restored.request.url) == _REDACTED_REQUEST_URL + assert restored.body == { "error": { "message": "bad request", "type": "invalid_request_error", @@ -142,6 +203,7 @@ def _assert_transport_status_error_pickle_round_trip(error: BadRequestError) -> "code": "bad_request", } } + assert restored.response.content == b"" assert restored.request.headers["Authorization"] == "" assert error.request.headers["Authorization"] != restored.request.headers["Authorization"] assert restored.response.elapsed >= timedelta(0) diff --git a/tests/test_httpx_compat.py b/tests/test_httpx_compat.py index cb00e9b00f..b283535fe1 100644 --- a/tests/test_httpx_compat.py +++ b/tests/test_httpx_compat.py @@ -1,16 +1,18 @@ from __future__ import annotations -import os import asyncio import importlib +import os +import pickle import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, cast -from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler from typing_extensions import override +import httpx2 import pytest -from openai import OpenAI, AsyncOpenAI +from openai import AsyncOpenAI, BadRequestError, OpenAI pytestmark = pytest.mark.skipif( os.environ.get("OPENAI_TEST_LEGACY_HTTPX") != "1", reason="requires the dedicated legacy HTTPX compatibility lane" @@ -53,6 +55,56 @@ async def handler(request: Any) -> Any: assert response.data is not None +def _assert_legacy_error_pickle_round_trip(error: BadRequestError) -> None: + payload = pickle.dumps(error) + restored = pickle.loads(payload) + + assert b"legacy-secret" not in payload + assert isinstance(restored.request, httpx2.Request) + assert isinstance(restored.response, httpx2.Response) + assert restored.request.headers["Authorization"] == "" + assert str(restored.request.url) == "https://redacted.invalid/" + assert restored.response.request is restored.request + assert restored.status_code == 400 + assert restored.body == {"error": "legacy bad request"} + + +def test_legacy_sync_httpx_error_pickle_round_trip() -> None: + httpx = cast(Any, importlib.import_module("httpx")) + + def handler(request: Any) -> Any: + return httpx.Response(400, request=request, json={"error": "legacy bad request"}) + + with OpenAI( + api_key="legacy-secret", + base_url="https://example.test/v1", + http_client=httpx.Client(transport=httpx.MockTransport(handler), trust_env=False), + max_retries=0, + ) as client: + with pytest.raises(BadRequestError) as exc_info: + client.models.list() + + _assert_legacy_error_pickle_round_trip(exc_info.value) + + +async def test_legacy_async_httpx_error_pickle_round_trip() -> None: + httpx = cast(Any, importlib.import_module("httpx")) + + async def handler(request: Any) -> Any: + return httpx.Response(400, request=request, json={"error": "legacy bad request"}) + + async with AsyncOpenAI( + api_key="legacy-secret", + base_url="https://example.test/v1", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler), trust_env=False), + max_retries=0, + ) as client: + with pytest.raises(BadRequestError) as exc_info: + await client.models.list() + + _assert_legacy_error_pickle_round_trip(exc_info.value) + + async def test_external_legacy_aiohttp_client_is_supported() -> None: httpx = cast(Any, importlib.import_module("httpx")) HttpxAiohttpClient = cast(Any, importlib.import_module("httpx_aiohttp")).HttpxAiohttpClient From 80d83cff1cbd9d73cb99cce8eea1e14b4dbe98bc Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:37:14 +0100 Subject: [PATCH 10/11] fix: harden serialized exception snapshots --- src/openai/_exceptions.py | 40 +++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index 4c0baee710..d54e4942fb 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -34,27 +34,24 @@ _RequestTimeoutSnapshot = dict[str, float | None] _RequestSnapshot = tuple[str, list[tuple[bytes, bytes]], _RequestTimeoutSnapshot | None] +_ResponseExtensionsSnapshot = dict[str, bytes] _ResponseSnapshot = tuple[ int, list[tuple[bytes, bytes]], _RequestSnapshot | None, timedelta | None, + _ResponseExtensionsSnapshot, ] _REDACTED_REQUEST_URL = "https://redacted.invalid/" _REDACTED_HEADER_VALUE = b"" -_SENSITIVE_HEADERS = { - b"api-key", - b"authorization", - b"cookie", - b"host", - b"proxy-authorization", - b"set-cookie", - b"x-amz-security-token", - b"x-api-key", - b"x-goog-api-key", +_SAFE_HEADER_VALUES = { + b"content-length", + b"content-type", + b"x-request-id", } _TIMEOUT_EXTENSION_KEYS = ("connect", "read", "write", "pool") +_RESPONSE_EXTENSION_KEYS = ("http_version", "reason_phrase") class OpenAIError(Exception): @@ -63,6 +60,9 @@ def __reduce__(self) -> tuple[object, tuple[object, ...]]: state = self.__dict__.copy() slot_state = _snapshot_slot_state(self) + if isinstance(self, WebSocketConnectionClosedError): + state["unsent_messages"] = [] + request_snapshot: _RequestSnapshot | None = None request = state.get("request") if _is_http_request(request): @@ -97,7 +97,7 @@ def _is_http_response(value: object) -> bool: def _snapshot_headers(headers: Any) -> list[tuple[bytes, bytes]]: return [ - (name, _REDACTED_HEADER_VALUE if name.lower() in _SENSITIVE_HEADERS else value) + (name, value if name.lower() in _SAFE_HEADER_VALUES else _REDACTED_HEADER_VALUE) for name, value in headers.raw ] @@ -135,6 +135,19 @@ def _restore_request(snapshot: _RequestSnapshot) -> httpx2.Request: return httpx2.Request(method, _REDACTED_REQUEST_URL, **kwargs) +def _snapshot_response_extensions(response: Any) -> _ResponseExtensionsSnapshot: + extensions = response.extensions + if not isinstance(extensions, dict): + return {} + + snapshot: _ResponseExtensionsSnapshot = {} + for key in _RESPONSE_EXTENSION_KEYS: + value = extensions.get(key) + if isinstance(value, bytes): + snapshot[key] = value + return snapshot + + def _snapshot_response(response: Any) -> _ResponseSnapshot: try: request_snapshot = _snapshot_request(response.request) @@ -151,6 +164,7 @@ def _snapshot_response(response: Any) -> _ResponseSnapshot: _snapshot_headers(response.headers), request_snapshot, elapsed, + _snapshot_response_extensions(response), ) @@ -159,13 +173,15 @@ def _restore_response( *, request: httpx2.Request | None, ) -> httpx2.Response: - status_code, headers, response_request_snapshot, elapsed = snapshot + status_code, headers, response_request_snapshot, elapsed, extensions = snapshot if request is None and response_request_snapshot is not None: request = _restore_request(response_request_snapshot) kwargs: dict[str, Any] = {"headers": headers} if request is not None: kwargs["request"] = request + if extensions: + kwargs["extensions"] = extensions response = httpx2.Response(status_code, **kwargs) if elapsed is not None: From 0f5c776f28be1dce7e3ebc966ea507daf8bbcb12 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:37:55 +0100 Subject: [PATCH 11/11] test: cover hardened exception snapshots --- tests/test_exceptions_pickle.py | 38 ++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/tests/test_exceptions_pickle.py b/tests/test_exceptions_pickle.py index 77ef7a5930..7f85c688d1 100644 --- a/tests/test_exceptions_pickle.py +++ b/tests/test_exceptions_pickle.py @@ -108,9 +108,12 @@ def test_request_private_data_is_omitted_and_timeout_is_preserved() -> None: [ ("Authorization", "Bearer sk-openai-secret"), ("api-key", "azure-secret-key"), + ("OpenAI-Organization", "org-private-identifier"), + ("OpenAI-Project", "proj-private-identifier"), + ("X-Customer-Metadata", "arbitrary-customer-data"), ], ) -def test_request_credentials_are_redacted_from_pickle(header_name: str, header_value: str) -> None: +def test_request_private_header_values_are_redacted(header_name: str, header_value: str) -> None: request = httpx2.Request( "POST", "https://example.test/v1/responses", @@ -155,6 +158,28 @@ def test_response_private_data_is_omitted_and_headers_are_redacted() -> None: assert restored.response.content == b"" +def test_response_protocol_metadata_is_preserved() -> None: + request = httpx2.Request("GET", "https://example.test/v1/models") + response = httpx2.Response( + 418, + request=request, + extensions={ + "http_version": b"HTTP/2", + "reason_phrase": b"Custom reason", + "network_stream": object(), + }, + ) + error = SubjectTokenProviderError("request failed", response=response) + + payload = pickle.dumps(error) + restored = pickle.loads(payload) + + assert restored.response is not None + assert restored.response.http_version == "HTTP/2" + assert restored.response.reason_phrase == "Custom reason" + assert "network_stream" not in restored.response.extensions + + def test_status_error_pickle_round_trip_preserves_response_state() -> None: request = httpx2.Request("POST", "https://example.test/v1/responses") response = httpx2.Response( @@ -247,14 +272,17 @@ def test_custom_error_subclass_preserves_slotted_state() -> None: assert restored.context == "slot context" -def test_websocket_error_pickle_round_trip_preserves_unsent_messages() -> None: +def test_websocket_error_pickle_omits_unsent_payloads() -> None: + sensitive_event = '{"type":"input_audio_buffer.append","audio":"private-audio"}' error = WebSocketConnectionClosedError( "connection closed", - unsent_messages=["first", "second"], + unsent_messages=[sensitive_event], ) - restored = pickle.loads(pickle.dumps(error)) + payload = pickle.dumps(error) + restored = pickle.loads(payload) assert isinstance(restored, WebSocketConnectionClosedError) assert str(restored) == "connection closed" - assert restored.unsent_messages == ["first", "second"] + assert b"private-audio" not in payload + assert restored.unsent_messages == []