diff --git a/.hark/changes/2026-10-14_zacchua_make-stripe-errors-serializable.change.md b/.hark/changes/2026-10-14_zacchua_make-stripe-errors-serializable.change.md new file mode 100644 index 000000000..55da12fc4 --- /dev/null +++ b/.hark/changes/2026-10-14_zacchua_make-stripe-errors-serializable.change.md @@ -0,0 +1,11 @@ +--- +title: Make Stripe errors serializable +pr_url: https://github.com/stripe/stripe-python/pull/1944 +semver_level: patch +jira_tickets_closed: +- RUN_DEVSDK-3443 +github_issues_resolved: +- https://github.com/stripe/stripe-python/issues/1941 +--- + +Stripe exceptions can now be serialized and deserialized with Python `pickle` and Celery JSON result serialization without losing their type or attributes. diff --git a/stripe/_error.py b/stripe/_error.py index 76e26bb4a..bcb8b8e1d 100644 --- a/stripe/_error.py +++ b/stripe/_error.py @@ -22,8 +22,6 @@ def __init__( headers: Optional[Dict[str, str]] = None, code: Optional[str] = None, ): - super(StripeError, self).__init__(message) - body: Optional[str] = None if http_body: # http_body can sometimes be a memoryview which must be cast @@ -46,6 +44,14 @@ def __init__( self.json_body = json_body self.headers = headers or {} self.code = code + super(StripeError, self).__init__( + message, + self.http_body, + self.http_status, + self.json_body, + self.headers, + self.code, + ) self.request_id = self.headers.get("request-id", None) self.error = self._construct_error_object() @@ -113,6 +119,7 @@ def __init__( message, http_body, http_status, json_body, headers, code ) self.should_retry = should_retry + self.args = self.args + (self.should_retry,) class StripeErrorWithParamCode(StripeError): @@ -146,6 +153,15 @@ def __init__( message, http_body, http_status, json_body, headers, code ) self.param = param + self.args = ( + self._message, + self.param, + self.code, + self.http_body, + self.http_status, + self.json_body, + self.headers, + ) class IdempotencyError(StripeError): @@ -167,6 +183,15 @@ def __init__( message, http_body, http_status, json_body, headers, code ) self.param = param + self.args = ( + self._message, + self.param, + self.code, + self.http_body, + self.http_status, + self.json_body, + self.headers, + ) class AuthenticationError(StripeError): @@ -185,6 +210,7 @@ class SignatureVerificationError(StripeError): def __init__(self, message, sig_header, http_body=None): super(SignatureVerificationError, self).__init__(message, http_body) self.sig_header = sig_header + self.args = (self._message, self.sig_header, self.http_body) # classDefinitions: The beginning of the section generated from our OpenAPI spec diff --git a/stripe/oauth_error.py b/stripe/oauth_error.py index 14f75c2af..5fd0fb1e8 100644 --- a/stripe/oauth_error.py +++ b/stripe/oauth_error.py @@ -15,6 +15,14 @@ def __init__( super(OAuthError, self).__init__( description, http_body, http_status, json_body, headers, code ) + self.args = ( + self.code, + self._message, + self.http_body, + self.http_status, + self.json_body, + self.headers, + ) def _construct_error_object(self): if self.json_body is None: diff --git a/tests/test_error.py b/tests/test_error.py index 73de812ac..f7d386eb9 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -2,8 +2,37 @@ import json - -from stripe._error import StripeError, CardError, APIConnectionError +import pickle + +import pytest + +from stripe._error import ( + APIConnectionError, + APIError, + AuthenticationError, + CardError, + IdempotencyError, + InvalidRequestError, + PermissionError, + RateLimitError, + SignatureVerificationError, + StripeError, + StripeErrorWithParamCode, + TemporarySessionExpiredError, +) + + +def assert_error_round_trips(err): + json_args = json.loads(json.dumps(err.args)) + round_tripped_errors = ( + pickle.loads(pickle.dumps(err)), + type(err)(*json_args), + ) + + for round_tripped in round_tripped_errors: + assert type(round_tripped) is type(err) + assert round_tripped.args == err.args + assert vars(round_tripped) == vars(err) class TestStripeError(object): @@ -114,3 +143,66 @@ def test_default_no_retry(self): err = APIConnectionError("msg", should_retry=True) assert err.should_retry + + +@pytest.mark.parametrize( + "error_class", + [ + StripeError, + APIError, + AuthenticationError, + IdempotencyError, + PermissionError, + RateLimitError, + StripeErrorWithParamCode, + TemporarySessionExpiredError, + ], +) +def test_stripe_error_round_trips(error_class): + err = error_class( + message="message", + http_body=b"body", + http_status=400, + json_body={"error": {"code": "error_code"}}, + headers={"request-id": "req_123"}, + code="error_code", + ) + + assert_error_round_trips(err) + + +@pytest.mark.parametrize( + "err", + [ + APIConnectionError( + "message", + b"body", + 400, + {"error": {"code": "error_code"}}, + {"request-id": "req_123"}, + "error_code", + True, + ), + CardError( + "message", + "param", + "error_code", + b"body", + 402, + {"error": {"code": "error_code"}}, + {"request-id": "req_123"}, + ), + InvalidRequestError( + message="message", + param="param", + code="error_code", + http_body=b"body", + http_status=400, + json_body={"error": {"code": "error_code"}}, + headers={"request-id": "req_123"}, + ), + SignatureVerificationError("message", "signature", b"body"), + ], +) +def test_stripe_error_with_custom_constructor_round_trips(err): + assert_error_round_trips(err) diff --git a/tests/test_oauth_error.py b/tests/test_oauth_error.py index 5eee7b66e..a8bf1e68d 100644 --- a/tests/test_oauth_error.py +++ b/tests/test_oauth_error.py @@ -1,6 +1,11 @@ # -*- coding: utf-8 -*- +import json +import pickle + +import pytest + from stripe import oauth_error @@ -11,3 +16,35 @@ def test_error_object(self): ) assert err.error is not None assert err.error.error == "some_oauth_error" + + +@pytest.mark.parametrize( + "error_class", + [ + oauth_error.OAuthError, + oauth_error.InvalidClientError, + oauth_error.InvalidGrantError, + oauth_error.InvalidRequestError, + oauth_error.InvalidScopeError, + oauth_error.UnsupportedGrantTypeError, + oauth_error.UnsupportedResponseTypeError, + ], +) +def test_oauth_error_round_trips(error_class): + err = error_class( + code="oauth_code", + description="description", + http_body=b"body", + http_status=400, + json_body={"error": "oauth_code"}, + headers={"request-id": "req_123"}, + ) + json_args = json.loads(json.dumps(err.args)) + + for round_tripped in ( + pickle.loads(pickle.dumps(err)), + error_class(*json_args), + ): + assert type(round_tripped) is error_class + assert round_tripped.args == err.args + assert vars(round_tripped) == vars(err)