nitroping 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nitroping/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ """Zero-dependency Python SDK for `nitroping <https://nitroping.dev>`_.
2
+
3
+ Send push notifications, register devices, verify webhook signatures.
4
+
5
+ Quick start::
6
+
7
+ from nitroping import Nitroping
8
+
9
+ np = Nitroping(api_key="np_live_...")
10
+ np.notifications.send(
11
+ title="Order #4129 shipped",
12
+ body="On its way",
13
+ target={"all": True},
14
+ )
15
+
16
+ See the project README for the full API reference, framework recipes,
17
+ and error table.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from ._client import AsyncNitroping, Nitroping
23
+ from ._devices import DevicesClient
24
+ from ._http import DEFAULT_BASE_URL, HttpClient
25
+ from ._notifications import NotificationsClient
26
+ from .errors import (
27
+ ApiError,
28
+ InvalidSignatureError,
29
+ MissingSignatureHeaderError,
30
+ NetworkError,
31
+ NitropingError,
32
+ TimestampOutOfRangeError,
33
+ )
34
+ from .types import (
35
+ DeactivateDeviceResult,
36
+ NotificationAction,
37
+ NotificationResult,
38
+ NotificationTarget,
39
+ Platform,
40
+ RegisterDeviceResult,
41
+ SendOptions,
42
+ TargetAll,
43
+ TargetDeviceIds,
44
+ TargetUserIds,
45
+ WebhookEvent,
46
+ )
47
+
48
+ __version__ = "0.1.0"
49
+
50
+ __all__ = [
51
+ "DEFAULT_BASE_URL",
52
+ "ApiError",
53
+ "AsyncNitroping",
54
+ "DeactivateDeviceResult",
55
+ "DevicesClient",
56
+ "HttpClient",
57
+ "InvalidSignatureError",
58
+ "MissingSignatureHeaderError",
59
+ "NetworkError",
60
+ "Nitroping",
61
+ "NitropingError",
62
+ "NotificationAction",
63
+ "NotificationResult",
64
+ "NotificationTarget",
65
+ "NotificationsClient",
66
+ "Platform",
67
+ "RegisterDeviceResult",
68
+ "SendOptions",
69
+ "TargetAll",
70
+ "TargetDeviceIds",
71
+ "TargetUserIds",
72
+ "TimestampOutOfRangeError",
73
+ "WebhookEvent",
74
+ "__version__",
75
+ ]
nitroping/_client.py ADDED
@@ -0,0 +1,204 @@
1
+ """Top-level SDK entry points: :class:`Nitroping` and :class:`AsyncNitroping`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from typing import Any
8
+
9
+ from ._devices import DevicesClient
10
+ from ._http import DEFAULT_BASE_URL, HttpClient
11
+ from ._notifications import NotificationsClient
12
+ from .errors import NitropingError
13
+ from .types import (
14
+ DeactivateDeviceResult,
15
+ NotificationAction,
16
+ NotificationResult,
17
+ NotificationTarget,
18
+ Platform,
19
+ RegisterDeviceResult,
20
+ )
21
+
22
+
23
+ class Nitroping:
24
+ """Synchronous server-side SDK client.
25
+
26
+ Example::
27
+
28
+ from nitroping import Nitroping
29
+
30
+ np = Nitroping(api_key="np_live_...")
31
+ result = np.notifications.send(
32
+ title="Order #4129 shipped",
33
+ body="On its way",
34
+ target={"all": True},
35
+ )
36
+ print(result["id"], result["status"])
37
+ """
38
+
39
+ #: ``notifications`` resource — send, get.
40
+ notifications: NotificationsClient
41
+ #: ``devices`` resource — register, deactivate.
42
+ devices: DevicesClient
43
+ #: Internal HTTP client. Exposed for advanced use (custom requests).
44
+ http: HttpClient
45
+
46
+ def __init__(
47
+ self,
48
+ api_key: str | None = None,
49
+ *,
50
+ base_url: str = DEFAULT_BASE_URL,
51
+ timeout: float = 30.0,
52
+ user_agent: str | None = None,
53
+ ) -> None:
54
+ resolved_key = api_key if api_key is not None else os.environ.get(
55
+ "NITROPING_API_KEY"
56
+ )
57
+ if not resolved_key:
58
+ raise NitropingError(
59
+ "api_key is required. Pass it to Nitroping(api_key=...) or set "
60
+ "the NITROPING_API_KEY environment variable.",
61
+ code="invalid_argument",
62
+ )
63
+ self.http = HttpClient(
64
+ api_key=resolved_key,
65
+ base_url=base_url,
66
+ timeout=timeout,
67
+ user_agent=user_agent,
68
+ )
69
+ self.notifications = NotificationsClient(self.http)
70
+ self.devices = DevicesClient(self.http)
71
+
72
+
73
+ class _AsyncNotificationsClient:
74
+ """Awaitable façade over :class:`NotificationsClient`.
75
+
76
+ Each call shells out to the sync client on the default thread pool
77
+ via :func:`asyncio.get_running_loop().run_in_executor`. This is a
78
+ pragmatic best-effort — for high-throughput async fanout, run the
79
+ underlying HTTP calls in a real async client (``httpx``, ``aiohttp``)
80
+ and skip this wrapper.
81
+ """
82
+
83
+ def __init__(self, inner: NotificationsClient) -> None:
84
+ self._inner = inner
85
+
86
+ async def send(
87
+ self,
88
+ *,
89
+ target: NotificationTarget,
90
+ title: str | None = None,
91
+ body: str | None = None,
92
+ template: str | None = None,
93
+ vars: dict[str, Any] | None = None,
94
+ data: dict[str, Any] | None = None,
95
+ icon: str | None = None,
96
+ image: str | None = None,
97
+ click_action: str | None = None,
98
+ deep_link: str | None = None,
99
+ actions: list[NotificationAction] | None = None,
100
+ scheduled_at: str | None = None,
101
+ expires_at: str | None = None,
102
+ idempotency_key: str | None = None,
103
+ ) -> NotificationResult:
104
+ loop = asyncio.get_running_loop()
105
+ return await loop.run_in_executor(
106
+ None,
107
+ lambda: self._inner.send(
108
+ target=target,
109
+ title=title,
110
+ body=body,
111
+ template=template,
112
+ vars=vars,
113
+ data=data,
114
+ icon=icon,
115
+ image=image,
116
+ click_action=click_action,
117
+ deep_link=deep_link,
118
+ actions=actions,
119
+ scheduled_at=scheduled_at,
120
+ expires_at=expires_at,
121
+ idempotency_key=idempotency_key,
122
+ ),
123
+ )
124
+
125
+ async def get(self, notification_id: str) -> dict[str, Any]:
126
+ loop = asyncio.get_running_loop()
127
+ return await loop.run_in_executor(
128
+ None, self._inner.get, notification_id
129
+ )
130
+
131
+
132
+ class _AsyncDevicesClient:
133
+ """Awaitable façade over :class:`DevicesClient`."""
134
+
135
+ def __init__(self, inner: DevicesClient) -> None:
136
+ self._inner = inner
137
+
138
+ async def register(
139
+ self,
140
+ *,
141
+ platform: Platform,
142
+ token: str,
143
+ user_id: str | None = None,
144
+ web_push_p256dh: str | None = None,
145
+ web_push_auth: str | None = None,
146
+ metadata: dict[str, Any] | None = None,
147
+ ) -> RegisterDeviceResult:
148
+ loop = asyncio.get_running_loop()
149
+ return await loop.run_in_executor(
150
+ None,
151
+ lambda: self._inner.register(
152
+ platform=platform,
153
+ token=token,
154
+ user_id=user_id,
155
+ web_push_p256dh=web_push_p256dh,
156
+ web_push_auth=web_push_auth,
157
+ metadata=metadata,
158
+ ),
159
+ )
160
+
161
+ async def deactivate(self, device_id: str) -> DeactivateDeviceResult:
162
+ loop = asyncio.get_running_loop()
163
+ return await loop.run_in_executor(
164
+ None, self._inner.deactivate, device_id
165
+ )
166
+
167
+
168
+ class AsyncNitroping:
169
+ """Async server-side SDK client.
170
+
171
+ Wraps the sync :class:`Nitroping` client and runs each HTTP call on
172
+ the default thread pool. Convenient when you want to ``await`` an
173
+ SDK method from inside an async framework (FastAPI, aiohttp,
174
+ Starlette) without pulling in a separate async HTTP dependency.
175
+
176
+ For real-world high-fanout (thousands of concurrent sends) prefer a
177
+ purpose-built async HTTP client and call the API directly — this
178
+ wrapper still bottlenecks on the executor pool size.
179
+ """
180
+
181
+ notifications: _AsyncNotificationsClient
182
+ devices: _AsyncDevicesClient
183
+
184
+ def __init__(
185
+ self,
186
+ api_key: str | None = None,
187
+ *,
188
+ base_url: str = DEFAULT_BASE_URL,
189
+ timeout: float = 30.0,
190
+ user_agent: str | None = None,
191
+ ) -> None:
192
+ self._sync = Nitroping(
193
+ api_key=api_key,
194
+ base_url=base_url,
195
+ timeout=timeout,
196
+ user_agent=user_agent,
197
+ )
198
+ self.notifications = _AsyncNotificationsClient(self._sync.notifications)
199
+ self.devices = _AsyncDevicesClient(self._sync.devices)
200
+
201
+ @property
202
+ def http(self) -> HttpClient:
203
+ """Underlying sync HTTP client (advanced use)."""
204
+ return self._sync.http
nitroping/_devices.py ADDED
@@ -0,0 +1,61 @@
1
+ """``devices`` resource client.
2
+
3
+ Mounted on :class:`nitroping.Nitroping` as ``np.devices``. Wraps
4
+ ``POST /api/v1/devices`` and ``DELETE /api/v1/devices/:id``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, cast
10
+ from urllib.parse import quote
11
+
12
+ from ._http import HttpClient
13
+ from .types import DeactivateDeviceResult, Platform, RegisterDeviceResult
14
+
15
+
16
+ class DevicesClient:
17
+ """Register and deactivate device rows."""
18
+
19
+ def __init__(self, http: HttpClient) -> None:
20
+ self._http = http
21
+
22
+ def register(
23
+ self,
24
+ *,
25
+ platform: Platform,
26
+ token: str,
27
+ user_id: str | None = None,
28
+ web_push_p256dh: str | None = None,
29
+ web_push_auth: str | None = None,
30
+ metadata: dict[str, Any] | None = None,
31
+ ) -> RegisterDeviceResult:
32
+ """Register (or update) a device with the secret API key.
33
+
34
+ Idempotent on ``(app_id, token, user_id)``. Returns
35
+ ``{"id": ..., "created": True}`` when a new row was inserted,
36
+ ``{"id": ..., "created": False}`` when an existing device matched.
37
+ """
38
+ wire: dict[str, Any] = {"token": token, "platform": platform}
39
+ if user_id is not None:
40
+ wire["user_id"] = user_id
41
+ if web_push_p256dh is not None:
42
+ wire["web_push_p256dh"] = web_push_p256dh
43
+ if web_push_auth is not None:
44
+ wire["web_push_auth"] = web_push_auth
45
+ if metadata is not None:
46
+ wire["metadata"] = metadata
47
+
48
+ response = self._http.request("POST", "/api/v1/devices", body=wire)
49
+ return cast(RegisterDeviceResult, response)
50
+
51
+ def deactivate(self, device_id: str) -> DeactivateDeviceResult:
52
+ """Soft-delete a device (sets ``status = inactive``).
53
+
54
+ Returns ``{"id": ..., "status": "inactive"}``. Raises
55
+ :class:`~nitroping.errors.ApiError` with ``code = "not_found"``
56
+ if the id doesn't belong to your app.
57
+ """
58
+ response = self._http.request(
59
+ "DELETE", f"/api/v1/devices/{quote(device_id, safe='')}"
60
+ )
61
+ return cast(DeactivateDeviceResult, response)
nitroping/_http.py ADDED
@@ -0,0 +1,158 @@
1
+ """Internal stdlib HTTP wrapper.
2
+
3
+ Adds the ``Authorization`` header, JSON-serializes the body, parses the
4
+ JSON response, and maps non-2xx envelopes (``{"error": {"code",
5
+ "message", "details"}}``) into :class:`ApiError`. Underlying transport
6
+ failure (DNS / TLS / offline / abort) becomes :class:`NetworkError`.
7
+
8
+ Zero runtime deps — :mod:`urllib.request` only.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import urllib.error
15
+ import urllib.request
16
+ from collections.abc import Mapping
17
+ from typing import Any, NoReturn
18
+
19
+ from .errors import ApiError, NetworkError, NitropingError
20
+
21
+ #: Default base URL pointing at the hosted nitroping service.
22
+ DEFAULT_BASE_URL = "https://nitroping.dev"
23
+
24
+ #: SDK version — bumped via ``pyproject.toml``. Used in the User-Agent
25
+ #: header so requests can be attributed during incident investigation.
26
+ SDK_VERSION = "0.1.0"
27
+
28
+
29
+ class HttpClient:
30
+ """Internal structured HTTP client.
31
+
32
+ Not part of the public surface — use :class:`nitroping.Nitroping`
33
+ instead. Exposed for advanced cases where callers need to make raw
34
+ requests against undocumented endpoints.
35
+ """
36
+
37
+ base_url: str
38
+ api_key: str
39
+ timeout: float
40
+ user_agent: str
41
+
42
+ def __init__(
43
+ self,
44
+ *,
45
+ api_key: str,
46
+ base_url: str = DEFAULT_BASE_URL,
47
+ timeout: float = 30.0,
48
+ user_agent: str | None = None,
49
+ ) -> None:
50
+ if not api_key:
51
+ raise NitropingError("api_key is required", code="invalid_argument")
52
+ self.api_key = api_key
53
+ self.base_url = base_url.rstrip("/")
54
+ self.timeout = timeout
55
+ self.user_agent = user_agent or f"nitroping-python/{SDK_VERSION}"
56
+
57
+ def request(
58
+ self,
59
+ method: str,
60
+ path: str,
61
+ *,
62
+ body: Any = None,
63
+ headers: Mapping[str, str] | None = None,
64
+ ) -> Any:
65
+ """Perform an HTTP request and parse the JSON envelope.
66
+
67
+ Returns the decoded JSON body (``dict`` for object responses,
68
+ ``list`` / ``str`` / ``None`` for everything else). Raises
69
+ :class:`ApiError` on non-2xx with a server envelope,
70
+ :class:`NetworkError` on transport failure.
71
+ """
72
+ url = self._build_url(path)
73
+ body_bytes: bytes | None = None
74
+
75
+ all_headers: dict[str, str] = {
76
+ "Authorization": f"ApiKey {self.api_key}",
77
+ "Accept": "application/json",
78
+ "User-Agent": self.user_agent,
79
+ }
80
+ if headers:
81
+ for k, v in headers.items():
82
+ all_headers[k] = v
83
+
84
+ if body is not None:
85
+ body_bytes = json.dumps(body).encode("utf-8")
86
+ all_headers["Content-Type"] = "application/json"
87
+
88
+ req = urllib.request.Request(
89
+ url=url, data=body_bytes, method=method, headers=all_headers
90
+ )
91
+
92
+ try:
93
+ with urllib.request.urlopen(req, timeout=self.timeout) as response:
94
+ raw = response.read()
95
+ except urllib.error.HTTPError as http_err:
96
+ # Non-2xx — try to decode the JSON error envelope, fall back
97
+ # to ``http_<status>`` if the body is not JSON.
98
+ err_body = http_err.read()
99
+ _raise_for_error(http_err.code, err_body)
100
+ except urllib.error.URLError as url_err:
101
+ raise NetworkError(
102
+ f"Request to {url} failed: {url_err.reason}", cause=url_err
103
+ ) from url_err
104
+ except TimeoutError as timeout_err:
105
+ raise NetworkError(
106
+ f"Request to {url} timed out", cause=timeout_err
107
+ ) from timeout_err
108
+ except OSError as os_err:
109
+ raise NetworkError(
110
+ f"Request to {url} failed: {os_err}", cause=os_err
111
+ ) from os_err
112
+
113
+ return _decode_body(raw)
114
+
115
+ def _build_url(self, path: str) -> str:
116
+ suffix = path if path.startswith("/") else f"/{path}"
117
+ return f"{self.base_url}{suffix}"
118
+
119
+
120
+ def _decode_body(raw: bytes) -> Any:
121
+ if not raw:
122
+ return None
123
+ text = raw.decode("utf-8", errors="replace")
124
+ try:
125
+ return json.loads(text)
126
+ except json.JSONDecodeError:
127
+ # 2xx with a non-JSON body — pass through as a string. We never
128
+ # hit this for the documented endpoints but it keeps the path
129
+ # forward-compatible.
130
+ return text
131
+
132
+
133
+ def _raise_for_error(status: int, raw: bytes) -> NoReturn:
134
+ """Translate an HTTPError body into an :class:`ApiError` with the
135
+ server's machine-readable code + per-field details."""
136
+ code = f"http_{status}"
137
+ message = f"HTTP {status}"
138
+ details: Any = None
139
+
140
+ if raw:
141
+ text = raw.decode("utf-8", errors="replace")
142
+ try:
143
+ envelope = json.loads(text)
144
+ except json.JSONDecodeError:
145
+ message = f"HTTP {status}: {text[:200]}"
146
+ else:
147
+ if isinstance(envelope, dict):
148
+ err = envelope.get("error")
149
+ if isinstance(err, dict):
150
+ raw_code = err.get("code")
151
+ if isinstance(raw_code, str) and raw_code:
152
+ code = raw_code
153
+ raw_message = err.get("message")
154
+ if isinstance(raw_message, str) and raw_message:
155
+ message = raw_message
156
+ details = err.get("details")
157
+
158
+ raise ApiError(message, status=status, code=code, details=details)
@@ -0,0 +1,94 @@
1
+ """``notifications`` resource client.
2
+
3
+ Mounted on :class:`nitroping.Nitroping` as ``np.notifications``. Wraps
4
+ ``POST /api/v1/notifications`` and ``GET /api/v1/notifications/:id``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, cast
10
+ from urllib.parse import quote
11
+
12
+ from ._http import HttpClient
13
+ from .types import NotificationAction, NotificationResult, NotificationTarget
14
+
15
+
16
+ class NotificationsClient:
17
+ """Send and inspect notifications."""
18
+
19
+ def __init__(self, http: HttpClient) -> None:
20
+ self._http = http
21
+
22
+ def send(
23
+ self,
24
+ *,
25
+ target: NotificationTarget,
26
+ title: str | None = None,
27
+ body: str | None = None,
28
+ template: str | None = None,
29
+ vars: dict[str, Any] | None = None,
30
+ data: dict[str, Any] | None = None,
31
+ icon: str | None = None,
32
+ image: str | None = None,
33
+ click_action: str | None = None,
34
+ deep_link: str | None = None,
35
+ actions: list[NotificationAction] | None = None,
36
+ scheduled_at: str | None = None,
37
+ expires_at: str | None = None,
38
+ idempotency_key: str | None = None,
39
+ ) -> NotificationResult:
40
+ """Enqueue a new notification.
41
+
42
+ Either ``title + body`` (raw payload) or ``template + vars``
43
+ (Pro plan). Mixing the two is a 422.
44
+
45
+ Returns ``{"id": ..., "status": ...}`` on ``201 Created``. On
46
+ non-2xx the SDK raises :class:`~nitroping.errors.ApiError`
47
+ carrying the server's ``code``, ``message``, and (for validation
48
+ failures) the per-field ``details`` map.
49
+ """
50
+ wire: dict[str, Any] = {"target": dict(target)}
51
+ if title is not None:
52
+ wire["title"] = title
53
+ if body is not None:
54
+ wire["body"] = body
55
+ if template is not None:
56
+ wire["template"] = template
57
+ if vars is not None:
58
+ wire["vars"] = vars
59
+ if data is not None:
60
+ wire["data"] = data
61
+ if icon is not None:
62
+ wire["icon"] = icon
63
+ if image is not None:
64
+ wire["image"] = image
65
+ if click_action is not None:
66
+ wire["click_action"] = click_action
67
+ if deep_link is not None:
68
+ wire["deep_link"] = deep_link
69
+ if actions is not None:
70
+ wire["actions"] = actions
71
+ if scheduled_at is not None:
72
+ wire["scheduled_at"] = scheduled_at
73
+ if expires_at is not None:
74
+ wire["expires_at"] = expires_at
75
+
76
+ headers: dict[str, str] = {}
77
+ if idempotency_key is not None:
78
+ headers["Idempotency-Key"] = idempotency_key
79
+
80
+ response = self._http.request(
81
+ "POST", "/api/v1/notifications", body=wire, headers=headers
82
+ )
83
+ return cast(NotificationResult, response)
84
+
85
+ def get(self, notification_id: str) -> dict[str, Any]:
86
+ """Fetch a previously-enqueued notification by id.
87
+
88
+ Returns the full row (including counters: ``total_sent``,
89
+ ``total_delivered``, ``total_failed``, etc.).
90
+ """
91
+ response = self._http.request(
92
+ "GET", f"/api/v1/notifications/{quote(notification_id, safe='')}"
93
+ )
94
+ return cast(dict[str, Any], response)
nitroping/errors.py ADDED
@@ -0,0 +1,110 @@
1
+ """Error hierarchy for the nitroping SDK.
2
+
3
+ All public functions raise subclasses of :class:`NitropingError`. Catch the
4
+ base class to handle every error, or narrow by ``isinstance`` on the
5
+ specific subclass when you want to switch on a known failure mode (e.g.
6
+ retry on :class:`NetworkError`, surface an HTTP 400 on
7
+ :class:`InvalidSignatureError`).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+
15
+ class NitropingError(Exception):
16
+ """Base error raised by every SDK function.
17
+
18
+ Subclasses set :attr:`code` and may add structured fields
19
+ (:attr:`status`, :attr:`details`).
20
+ """
21
+
22
+ #: Optional HTTP status if this error originated from a response.
23
+ status: int | None
24
+
25
+ #: Stable machine-readable code, mirrored from the server envelope
26
+ #: (``error.code``). Examples: ``"invalid_api_key"``,
27
+ #: ``"validation_failed"``, ``"quota_exceeded"``. SDK-internal failures
28
+ #: use codes like ``"network_error"`` or ``"invalid_signature"``.
29
+ code: str
30
+
31
+ #: Free-form details object — typically the server's ``error.details``
32
+ #: (field-level validation errors).
33
+ details: Any
34
+
35
+ def __init__(
36
+ self,
37
+ message: str,
38
+ *,
39
+ status: int | None = None,
40
+ code: str = "error",
41
+ details: Any = None,
42
+ cause: BaseException | None = None,
43
+ ) -> None:
44
+ super().__init__(message)
45
+ self.status = status
46
+ self.code = code
47
+ self.details = details
48
+ if cause is not None:
49
+ self.__cause__ = cause
50
+
51
+
52
+ class ApiError(NitropingError):
53
+ """Raised when the server returns a non-2xx response.
54
+
55
+ Carries the server's ``code``, ``message``, and (for validation
56
+ failures) the per-field ``details`` map.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ message: str,
62
+ *,
63
+ status: int,
64
+ code: str = "error",
65
+ details: Any = None,
66
+ ) -> None:
67
+ super().__init__(message, status=status, code=code, details=details)
68
+
69
+
70
+ class NetworkError(NitropingError):
71
+ """Raised when the HTTP transport itself fails.
72
+
73
+ Wraps DNS / TLS / offline / abort errors. The underlying exception is
74
+ attached via :attr:`__cause__`.
75
+ """
76
+
77
+ def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
78
+ super().__init__(message, code="network_error", cause=cause)
79
+
80
+
81
+ class InvalidSignatureError(NitropingError):
82
+ """Raised by :func:`nitroping.webhooks.verify` when the HMAC does not
83
+ match the request body, or the signature header is malformed."""
84
+
85
+ def __init__(
86
+ self, message: str = "Webhook signature does not match request body"
87
+ ) -> None:
88
+ super().__init__(message, code="invalid_signature")
89
+
90
+
91
+ class TimestampOutOfRangeError(NitropingError):
92
+ """Raised by :func:`nitroping.webhooks.verify` when the signature is
93
+ well-formed and matches the body, but its ``t=`` timestamp is outside
94
+ the tolerance window. Defends against signature replay."""
95
+
96
+ def __init__(
97
+ self,
98
+ message: str = "Webhook timestamp is outside the allowed tolerance",
99
+ ) -> None:
100
+ super().__init__(message, code="timestamp_out_of_range")
101
+
102
+
103
+ class MissingSignatureHeaderError(NitropingError):
104
+ """Raised by :func:`nitroping.webhooks.verify` when the
105
+ ``X-Nitroping-Signature`` header is absent."""
106
+
107
+ def __init__(
108
+ self, message: str = "Missing X-Nitroping-Signature header"
109
+ ) -> None:
110
+ super().__init__(message, code="missing_signature_header")
nitroping/py.typed ADDED
File without changes