From 6104b7d1339bcc4c072542f7c2d5137d77acb426 Mon Sep 17 00:00:00 2001 From: Matthew Bayer Date: Thu, 26 Feb 2026 11:11:07 -0500 Subject: [PATCH] Fix ConjureHTTPError pickling crash in multiprocessing BaseException.__reduce__ passes the string message to __init__ on unpickle, but ConjureHTTPError.__init__ expects an HTTPError object. This causes an AttributeError crash when a ConjureHTTPError propagates across a ProcessPoolExecutor boundary. Override __reduce__ to bypass __init__ on reconstruction, matching the existing __copy__ fix. Co-Authored-By: Claude Opus 4.6 --- .../_http/requests_client.py | 34 ++++++++----------- test/_http/test_requests_client.py | 20 +++++++++++ 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/conjure_python_client/_http/requests_client.py b/conjure_python_client/_http/requests_client.py index 4d776226..beb8c29d 100644 --- a/conjure_python_client/_http/requests_client.py +++ b/conjure_python_client/_http/requests_client.py @@ -255,6 +255,14 @@ def __setstate__(self, state): super().__setstate__(state) # type: ignore +def _reconstruct_conjure_http_error(cls, state): + obj = cls.__new__(cls) + args = state.pop("args", ()) + obj.__dict__.update(state) + obj.args = args + return obj + + class ConjureHTTPError(HTTPError): """An HTTPError from a Conjure Service with ``SerializableError`` attributes extracted from the response.""" @@ -295,30 +303,16 @@ def __init__(self, http_error: HTTPError) -> None: ) def __copy__(self): - """The fact that ConjureHTTPError is a BaseException but its __init__ - has a different signature causes a subtle issue for shallow copying. - During copy.copy(), __init__ will be called with args defined by - BaseException.__reduce_, which corresponds to default __init__. Since - they're inconsistent, what http_error receives is actually message, - hence an error. - - By defining a __copy__ method, we give instructions to the intepreter - on how to reconstruct a ConjureHTTPError instance. Alternatively, we - could also fix it by changing the _init__ signature of this class. - Although cleaner, unfortunately it will be a breaking change. - """ - - # Create a shell object without calling __init__ new_obj = type(self).__new__(type(self)) - - for attr, value in self.__dict__.items(): - setattr(new_obj, attr, value) - - # Exception args are not actually a part of __dict__... + new_obj.__dict__.update(self.__dict__) new_obj.args = self.args - return new_obj + def __reduce__(self): + state = self.__dict__.copy() + state["args"] = self.args + return (_reconstruct_conjure_http_error, (type(self), state)) + @property def cause(self) -> Optional[HTTPError]: """The wrapped ``HTTPError`` that was the direct cause of diff --git a/test/_http/test_requests_client.py b/test/_http/test_requests_client.py index 76399afd..77af13b1 100644 --- a/test/_http/test_requests_client.py +++ b/test/_http/test_requests_client.py @@ -77,3 +77,23 @@ def json(self): copied_error = copy.copy(original_error) assert type(original_error) is type(copied_error) assert str(original_error) == str(copied_error) + + +def test_pickling_conjure_http_error(): + from requests.models import Response, PreparedRequest + + response = Response() + response.status_code = 500 + response.headers["X-B3-TraceId"] = "test" + response._content = b'{"errorCode": "INTERNAL", "errorName": "Default:Internal", "errorInstanceId": "abc", "parameters": {}}' + request = PreparedRequest() + request.prepare_url("http://example.com", {}) + request.prepare_method("GET") + response.request = request + + http_error = HTTPError("HTTP 500 Server Error", response=response, request=request) + original_error = ConjureHTTPError(http_error) + + unpickled_error = pickle.loads(pickle.dumps(original_error)) + assert type(original_error) is type(unpickled_error) + assert str(original_error) == str(unpickled_error)