From 4e1c598171b832a4cf433de80f1507dec4cf7953 Mon Sep 17 00:00:00 2001 From: David Grayston Date: Sun, 5 Oct 2025 23:55:52 +0100 Subject: [PATCH 1/2] feat: Add retry_after property to ApiError --- CHANGELOG.md | 1 + paddle_billing/Exceptions/ApiError.py | 8 ++++--- tests/Functional/Client/test_Client.py | 29 ++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd6cf33..ae301104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Check our main [developer changelog](https://developer.paddle.com/?utm_source=dx - Support `retained_fee` field on totals objects to show the fees retained by Paddle for the adjustment. - Added support for new payment methods `blik`, `mb_way`, `pix` and `upi`. See [related changelog](https://developer.paddle.com/changelog/2025/blik-mbway-payment-methods?utm_source=dx&utm_medium=paddle-python-sdk). +- `ApiError` will now have `retry_after` property set for [too_many_requests](https://developer.paddle.com/errors/shared/too_many_requests?utm_source=dx&utm_medium=paddle-python-sdk) errors ## 1.10.0 - 2025-08-15 diff --git a/paddle_billing/Exceptions/ApiError.py b/paddle_billing/Exceptions/ApiError.py index f9a51806..e97e5223 100644 --- a/paddle_billing/Exceptions/ApiError.py +++ b/paddle_billing/Exceptions/ApiError.py @@ -1,16 +1,18 @@ from paddle_billing.Exceptions.FieldError import FieldError -from requests import HTTPError +from requests import HTTPError, Response class ApiError(HTTPError): - def __init__(self, response, error_type, error_code, detail, docs_url, *field_errors): + def __init__(self, response: Response, error_type: str, error_code: str, detail: str, docs_url: str, *field_errors): super().__init__(detail, response=response) self.error_type = error_type self.error_code = error_code self.detail = detail self.docs_url = docs_url self.field_errors = field_errors + retry_after = response.headers.get("Retry-After") + self.retry_after = int(retry_after) if retry_after else None def __repr__(self): return ( @@ -19,6 +21,6 @@ def __repr__(self): ) @classmethod - def from_error_data(cls, response, error): + def from_error_data(cls, response: Response, error): field_errors = [FieldError(fe["field"], fe["message"]) for fe in error.get("errors", [])] return cls(response, error["type"], error["code"], error["detail"], error["documentation_url"], *field_errors) diff --git a/tests/Functional/Client/test_Client.py b/tests/Functional/Client/test_Client.py index 68fb6478..b6537691 100644 --- a/tests/Functional/Client/test_Client.py +++ b/tests/Functional/Client/test_Client.py @@ -19,6 +19,8 @@ class TestClient: "expected_reason", "expected_response_body", "expected_exception", + "headers", + "expected_retry_after", ], [ ( @@ -35,6 +37,8 @@ class TestClient: "meta": {"request_id": "f00bb3ca-399d-4686-889c-50b028f4c912"}, }, ApiError, + {}, + None, ), ( 404, @@ -49,6 +53,8 @@ class TestClient: "meta": {"request_id": "f00bb3ca-399d-4686-889c-50b028f4c912"}, }, ApiError, + {}, + None, ), ( 400, @@ -63,12 +69,31 @@ class TestClient: "meta": {"request_id": "f00bb3ca-399d-4686-889c-50b028f4c912"}, }, AddressApiError, + {}, + None, + ), + ( + 429, + "Too Many Requests", + { + "error": { + "type": "request_error", + "code": "too_many_requests", + "detail": "IP address exceeded the allowed rate limit. Retry after the number of seconds in the Retry-After header.", + "documentation_url": "https://developer.paddle.com/errors/shared/too_many_requests", + }, + "meta": {"request_id": "f00bb3ca-399d-4686-889c-50b028f4c912"}, + }, + ApiError, + {"Retry-After": "42"}, + 42, ), ], ids=[ "Returns bad_request response", "Returns not_found response", "Returns address_location_not_allowed response", + "Returns too_many_requests response", ], ) def test_post_raw_returns_error_response( @@ -80,6 +105,8 @@ def test_post_raw_returns_error_response( expected_reason, expected_response_body, expected_exception, + headers, + expected_retry_after, ): expected_request_url = f"{test_client.base_url}/some/url" expected_request_body = {"some_property": "some value"} @@ -88,6 +115,7 @@ def test_post_raw_returns_error_response( status_code=expected_response_status, text=dumps(expected_response_body), reason=expected_reason, + headers=headers, ) with raises(expected_exception) as exception_info: @@ -111,6 +139,7 @@ def test_post_raw_returns_error_response( assert api_error.error_code == expected_response_body["error"]["code"] assert api_error.detail == expected_response_body["error"]["detail"] assert api_error.docs_url == expected_response_body["error"]["documentation_url"] + assert api_error.retry_after == expected_retry_after if "errors" in expected_response_body["error"]: assert len(api_error.field_errors) == len(expected_response_body["error"]["errors"]) From 186ad7fdda3292197a365022a6642410fcb930e6 Mon Sep 17 00:00:00 2001 From: David Grayston Date: Mon, 6 Oct 2025 15:04:42 +0100 Subject: [PATCH 2/2] docs: Update README and example --- README.md | 16 ++++++++++++++++ examples/get_products.py | 1 + 2 files changed, 17 insertions(+) diff --git a/README.md b/README.md index d4e3d688..a8444e73 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,22 @@ paddle = Client('PADDLE_API_SECRET_KEY') deleted_product = paddle.products.delete('PRODUCT_ID') ``` +### Error Handling + +If a request fails, Paddle raises an `ApiError` that contains the same information as [errors returned by the API](https://developer.paddle.com/api-reference/about/errors?utm_source=dx&utm_medium=paddle-python-sdk). You can use the `code` attribute to search an error in [the error reference](https://developer.paddle.com/errors/overview?utm_source=dx&utm_medium=paddle-python-sdk) and to handle the error in your app. Validation errors also return an array of `errors` that tell you which fields failed validation. The `retry_after` property will be set for `too_many_requests` errors. + +This example shows how to handle an error with the code `conflict`: + +```python +from paddle_billing.Exceptions.ApiError import ApiError + +try: + # Call functions from the SDK +except ApiError as error: + # error.error_code will always follow the error code defined in our documentation + if error.error_code == 'conflict': + # Handle Conflict error +``` ## Resources diff --git a/examples/get_products.py b/examples/get_products.py index 4ef17d4d..e85bd705 100644 --- a/examples/get_products.py +++ b/examples/get_products.py @@ -32,6 +32,7 @@ print(f"detail: {error.detail}") print(f"field_errors: {error.field_errors}") print(f"response.status_code: {error.response.status_code}") + print(f"retry_after: {error.retry_after}") except Exception as error: log.error(f"We received an error listing products: {error}")