From 9e74704b9af516356f90ab8487dd76dfc82fd551 Mon Sep 17 00:00:00 2001 From: Ali Albarak Date: Fri, 28 Aug 2026 23:41:47 +0300 Subject: [PATCH 1/2] feat: expose push response retry metadata --- CHANGELOG.md | 5 +++++ README.md | 3 +++ pywebpush/__init__.py | 27 ++++++++++++++++++++++++++- pywebpush/tests/test_webpush.py | 13 +++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6358a5c..fa38457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # I am terrible at keeping this up-to-date. +## Unreleased + +- Add common `status_code` and `retry_after` accessors to `WebPushException` + for synchronous and asynchronous responses. + ## 2.3.0 (2026-02-09) - Cleanup from @Rotzbua diff --git a/README.md b/README.md index 1462b96..f8e8018 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,9 @@ try: ) except WebPushException as ex: print("I'm sorry, Dave, but I can't do that: {}", repr(ex)) + # status_code works with both webpush() and webpush_async(). + if ex.status_code in (429, 503): + print("Push service requested a retry after:", ex.retry_after) # Mozilla returns additional information in the body of the response. if ex.response is not None and ex.response.json(): extra = ex.response.json() diff --git a/pywebpush/__init__.py b/pywebpush/__init__.py index 00f5479..267850a 100644 --- a/pywebpush/__init__.py +++ b/pywebpush/__init__.py @@ -25,7 +25,11 @@ class WebPushException(Exception): """Web Push failure. - This may contain the requests.Response + This may contain a requests.Response or aiohttp.ClientResponse. + + ``status_code`` and ``retry_after`` provide a common interface for + inspecting either response type without discarding the original + ``response`` object. """ @@ -44,6 +48,27 @@ def __str__(self) -> str: extra = f", Response {self.response}" return f"WebPushException: {self.message}{extra}" + @property + def status_code(self) -> int | None: + """Return the HTTP status for synchronous or asynchronous responses.""" + if self.response is None: + return None + return getattr( + self.response, + "status_code", + getattr(self.response, "status", None), + ) + + @property + def retry_after(self) -> str | None: + """Return the provider's Retry-After header, when present.""" + if self.response is None: + return None + headers = getattr(self.response, "headers", None) + if not headers: + return None + return headers.get("Retry-After") + class NoData(Exception): """Message contained No Data, no encoding required.""" diff --git a/pywebpush/tests/test_webpush.py b/pywebpush/tests/test_webpush.py index ecab2bd..555a2db 100644 --- a/pywebpush/tests/test_webpush.py +++ b/pywebpush/tests/test_webpush.py @@ -563,6 +563,8 @@ def test_exception(self): exp = WebPushException("foo") assert f"{exp}" == "WebPushException: foo" + assert exp.status_code is None + assert exp.retry_after is None # Really should try to load the response to verify, but this mock # covers what we need. response = Mock(spec=Response) @@ -577,9 +579,20 @@ def test_exception(self): response.json.return_value = json.loads(response.text) response.status_code = 401 response.reason = "Unauthorized" + response.headers = {"Retry-After": "120"} exp = WebPushException("foo", response) assert f"{exp}" == "WebPushException: foo, Response {}".format(response.text) assert f"{exp.response}", "" assert cast(requests.Response, exp.response).json().get("errno") == 109 + assert exp.status_code == 401 + assert exp.retry_after == "120" + + async_response = Mock(spec=["status", "headers", "text"]) + async_response.status = 503 + async_response.headers = {"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"} + exp = WebPushException("async failure", async_response) + assert exp.status_code == 503 + assert exp.retry_after == "Wed, 21 Oct 2015 07:28:00 GMT" + exp = WebPushException("foo", [1, 2, 3]) assert f"{exp}" == "WebPushException: foo, Response [1, 2, 3]" From e7b59ffb773b644f8c3c75a8ef054dcf757e6ec7 Mon Sep 17 00:00:00 2001 From: Ali Albarak Date: Sun, 30 Aug 2026 00:00:22 +0300 Subject: [PATCH 2/2] chore: address review feedback --- CHANGELOG.md | 2 +- README.md | 2 ++ pyproject.toml | 2 +- pywebpush/__init__.py | 13 ++++++------- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa38457..45ed3f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # I am terrible at keeping this up-to-date. -## Unreleased +## 2.5.0 - Add common `status_code` and `retry_after` accessors to `WebPushException` for synchronous and asynchronous responses. diff --git a/README.md b/README.md index f8e8018..b5de5c4 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ except WebPushException as ex: # status_code works with both webpush() and webpush_async(). if ex.status_code in (429, 503): print("Push service requested a retry after:", ex.retry_after) + # retry_after is either a delay in seconds or an HTTP date: + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After # Mozilla returns additional information in the body of the response. if ex.response is not None and ex.response.json(): extra = ex.response.json() diff --git a/pyproject.toml b/pyproject.toml index 3041210..ad6508d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "pywebpush" -version = "2.4.0" +version = "2.5.0" # PYTHON_VER requires-python = ">= 3.10" license = "MPL-2.0" diff --git a/pywebpush/__init__.py b/pywebpush/__init__.py index 267850a..74b6bb9 100644 --- a/pywebpush/__init__.py +++ b/pywebpush/__init__.py @@ -61,13 +61,12 @@ def status_code(self) -> int | None: @property def retry_after(self) -> str | None: - """Return the provider's Retry-After header, when present.""" - if self.response is None: - return None - headers = getattr(self.response, "headers", None) - if not headers: - return None - return headers.get("Retry-After") + """Return the provider's Retry-After header, when present. + + The value can be either a delay in seconds or an HTTP date. See + https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After + """ + return getattr(self.response, "headers", {}).get("Retry-After", None) class NoData(Exception):