pingloom 0.1.0__tar.gz

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.
@@ -0,0 +1,42 @@
1
+ .local/
2
+ node_modules/
3
+ .venv/
4
+ .next/
5
+ __pycache__/
6
+ *.pyc
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ test-results/
10
+ playwright-report/
11
+ .playwright-mcp/
12
+ .playwright-cli/
13
+ output/
14
+ Codex UI UX Pro Max/
15
+ .superpowers/
16
+ .env*
17
+ !.env.example
18
+ !.env.uat.example
19
+ apps/mobile/android/
20
+ apps/mobile/ios/
21
+ apps/mobile/.expo/
22
+ apps/mobile/expo-env.d.ts
23
+ apps/mobile/firebase/*.json
24
+ apps/mobile/firebase/*.plist
25
+ apps/mobile/firebase/uat/*.json
26
+ apps/mobile/firebase/uat/*.plist
27
+ .local/maestro/
28
+
29
+ # local screenshots for videos (see media/README.md)
30
+ media/
31
+ .next-e2e/
32
+
33
+ # deployment
34
+ infra/docker/.env
35
+ infra/docker/secrets/*
36
+ !infra/docker/secrets/.gitkeep
37
+ .deploy/
38
+ backups/
39
+
40
+ # Claude Code agent worktrees
41
+ .claude/worktrees/
42
+ .vercel
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.5
2
+ Name: pingloom
3
+ Version: 0.1.0
4
+ Summary: Python client for the PingLoom notification API (server-side only).
5
+ Project-URL: Homepage, https://pingloom.dev
6
+ Author: Srish Rachamalla
7
+ License: MIT
8
+ Keywords: alerts,notifications,on-call,pingloom,push,webhook
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.9
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=8; extra == 'test'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # pingloom (Python)
18
+
19
+ Python client for the [PingLoom](https://pingloom.dev) notification API. Standard library only, Python 3.9+.
20
+
21
+ Server-side only: the API key (`pl_live_...` or `pl_test_...`) must never go into browser or mobile code.
22
+
23
+ ```bash
24
+ pip install pingloom
25
+ export PINGLOOM_API_KEY=pl_test_...
26
+ ```
27
+
28
+ ```python
29
+ from pingloom import PingLoom
30
+
31
+ pl = PingLoom() # reads PINGLOOM_API_KEY; talks to https://api.pingloom.dev (override with PINGLOOM_BASE_URL)
32
+ event_id = pl.send(
33
+ "Backup finished", body="42 GB in 3 min", channel="ops", priority="high", idempotency_key="backup-2026-10-30"
34
+ )
35
+ print(pl.status(event_id)["status"]) # provider_accepted means FCM accepted it, not that a phone got it
36
+ ```
37
+
38
+ `send(title, body=None, channel=None, priority=None, data=None, action_url=None, idempotency_key=None)` returns the event ID. `status(event_id)` needs the `events:read` scope.
39
+
40
+ ## Errors and retries
41
+
42
+ Every API error is a typed exception carrying `status`, `code`, `message`, `request_id` and `details`:
43
+
44
+ | Exception | When |
45
+ | --- | --- |
46
+ | `InvalidRequestError` | 400, 413, 415, 422 |
47
+ | `AuthenticationError` | 401 |
48
+ | `PermissionDeniedError` | 403 |
49
+ | `NotFoundError` | 404 |
50
+ | `ConflictError` | 409 (`IDEMPOTENCY_CONFLICT`, `PROJECT_ARCHIVED`) |
51
+ | `RateLimitError` | 429 `RATE_LIMITED`, after retries |
52
+ | `QuotaExceededError` | 429 `QUOTA_EXCEEDED`, never retried |
53
+ | `ServerError` | 5xx, after retries |
54
+ | `NetworkError` | no response, after retries |
55
+ | `ConfigurationError` | missing key or base URL |
56
+
57
+ `429 RATE_LIMITED`, `5xx` and network errors are retried (default 3 times) with exponential backoff, or `Retry-After` when sent. Retries reuse the same `Idempotency-Key`, so they cannot create duplicates. When you do not pass `idempotency_key`, a random one is generated per call. Pass your own, derived from the business event, to make repeated calls of your own safe too.
58
+
59
+ The key is never logged: `repr(client)` and error text show `pl_live_****abcd`.
60
+
61
+ ## Develop
62
+
63
+ ```bash
64
+ pip install pytest
65
+ python -m pytest
66
+ ```
@@ -0,0 +1,50 @@
1
+ # pingloom (Python)
2
+
3
+ Python client for the [PingLoom](https://pingloom.dev) notification API. Standard library only, Python 3.9+.
4
+
5
+ Server-side only: the API key (`pl_live_...` or `pl_test_...`) must never go into browser or mobile code.
6
+
7
+ ```bash
8
+ pip install pingloom
9
+ export PINGLOOM_API_KEY=pl_test_...
10
+ ```
11
+
12
+ ```python
13
+ from pingloom import PingLoom
14
+
15
+ pl = PingLoom() # reads PINGLOOM_API_KEY; talks to https://api.pingloom.dev (override with PINGLOOM_BASE_URL)
16
+ event_id = pl.send(
17
+ "Backup finished", body="42 GB in 3 min", channel="ops", priority="high", idempotency_key="backup-2026-10-30"
18
+ )
19
+ print(pl.status(event_id)["status"]) # provider_accepted means FCM accepted it, not that a phone got it
20
+ ```
21
+
22
+ `send(title, body=None, channel=None, priority=None, data=None, action_url=None, idempotency_key=None)` returns the event ID. `status(event_id)` needs the `events:read` scope.
23
+
24
+ ## Errors and retries
25
+
26
+ Every API error is a typed exception carrying `status`, `code`, `message`, `request_id` and `details`:
27
+
28
+ | Exception | When |
29
+ | --- | --- |
30
+ | `InvalidRequestError` | 400, 413, 415, 422 |
31
+ | `AuthenticationError` | 401 |
32
+ | `PermissionDeniedError` | 403 |
33
+ | `NotFoundError` | 404 |
34
+ | `ConflictError` | 409 (`IDEMPOTENCY_CONFLICT`, `PROJECT_ARCHIVED`) |
35
+ | `RateLimitError` | 429 `RATE_LIMITED`, after retries |
36
+ | `QuotaExceededError` | 429 `QUOTA_EXCEEDED`, never retried |
37
+ | `ServerError` | 5xx, after retries |
38
+ | `NetworkError` | no response, after retries |
39
+ | `ConfigurationError` | missing key or base URL |
40
+
41
+ `429 RATE_LIMITED`, `5xx` and network errors are retried (default 3 times) with exponential backoff, or `Retry-After` when sent. Retries reuse the same `Idempotency-Key`, so they cannot create duplicates. When you do not pass `idempotency_key`, a random one is generated per call. Pass your own, derived from the business event, to make repeated calls of your own safe too.
42
+
43
+ The key is never logged: `repr(client)` and error text show `pl_live_****abcd`.
44
+
45
+ ## Develop
46
+
47
+ ```bash
48
+ pip install pytest
49
+ python -m pytest
50
+ ```
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pingloom"
7
+ version = "0.1.0"
8
+ description = "Python client for the PingLoom notification API (server-side only)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Srish Rachamalla" }]
13
+ keywords = ["pingloom", "notifications", "push", "alerts", "on-call", "webhook"]
14
+ dependencies = []
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Typing :: Typed",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://pingloom.dev"
23
+
24
+ [project.optional-dependencies]
25
+ test = ["pytest>=8"]
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/pingloom"]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
32
+ pythonpath = ["src"]
33
+
34
+ [tool.ruff]
35
+ line-length = 120
36
+ target-version = "py39"
@@ -0,0 +1,6 @@
1
+ # Standalone config: the SDK supports Python 3.9, so the repo-wide py312 rules (X | None) do not apply here.
2
+ line-length = 120
3
+ target-version = "py39"
4
+
5
+ [lint]
6
+ select = ["E", "F", "I", "B"]
@@ -0,0 +1,33 @@
1
+ """PingLoom Python client."""
2
+
3
+ from ._version import __version__
4
+ from .client import PingLoom
5
+ from .errors import (
6
+ AuthenticationError,
7
+ ConfigurationError,
8
+ ConflictError,
9
+ InvalidRequestError,
10
+ NetworkError,
11
+ NotFoundError,
12
+ PermissionDeniedError,
13
+ PingLoomError,
14
+ QuotaExceededError,
15
+ RateLimitError,
16
+ ServerError,
17
+ )
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ "PingLoom",
22
+ "PingLoomError",
23
+ "ConfigurationError",
24
+ "NetworkError",
25
+ "InvalidRequestError",
26
+ "AuthenticationError",
27
+ "PermissionDeniedError",
28
+ "NotFoundError",
29
+ "ConflictError",
30
+ "RateLimitError",
31
+ "QuotaExceededError",
32
+ "ServerError",
33
+ ]
@@ -0,0 +1,25 @@
1
+ """Key masking. The API key must never appear in logs, errors or reprs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Optional
7
+
8
+ _KEY_RE = re.compile(r"pl_(?:live|test)_[A-Za-z0-9_\-]{4,}")
9
+
10
+
11
+ def mask_key(key: Optional[str]) -> str:
12
+ """Return `pl_live_****abcd` for a key, or `****` when it does not look like one."""
13
+ if not key:
14
+ return "(no key)"
15
+ m = re.match(r"^(pl_(?:live|test)_)", key)
16
+ if m and len(key) >= len(m.group(1)) + 8:
17
+ return f"{m.group(1)}****{key[-4:]}"
18
+ return "****"
19
+
20
+
21
+ def scrub(text: str, key: Optional[str] = None) -> str:
22
+ """Mask the known key and anything shaped like a PingLoom key inside free text."""
23
+ if key:
24
+ text = text.replace(key, mask_key(key))
25
+ return _KEY_RE.sub(lambda m: mask_key(m.group(0)), text)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,171 @@
1
+ """PingLoom client. Standard library only (urllib)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ import time
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ import uuid
13
+ from typing import Any, Callable, Dict, Mapping, Optional
14
+
15
+ from ._mask import mask_key, scrub
16
+ from ._version import __version__
17
+ from .errors import ConfigurationError, NetworkError, PingLoomError, error_for
18
+
19
+ # TODO(T07/T08): add heartbeat() and decision helpers here once those endpoints are merged.
20
+
21
+ _RETRY_CAP_SECONDS = 30.0
22
+
23
+
24
+ DEFAULT_BASE_URL = "https://api.pingloom.dev"
25
+
26
+ class PingLoom:
27
+ """Server-side client. Never use it in browser or mobile code: the key must stay on your server.
28
+
29
+ `api_key` defaults to $PINGLOOM_API_KEY and `base_url` to $PINGLOOM_BASE_URL, else the hosted API.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ api_key: Optional[str] = None,
35
+ base_url: Optional[str] = None,
36
+ *,
37
+ timeout: float = 10.0,
38
+ max_retries: int = 3,
39
+ backoff: float = 0.5,
40
+ _sleep: Callable[[float], None] = time.sleep,
41
+ ) -> None:
42
+ self._api_key = api_key or os.environ.get("PINGLOOM_API_KEY") or ""
43
+ url = base_url or os.environ.get("PINGLOOM_BASE_URL") or DEFAULT_BASE_URL
44
+ if not self._api_key:
45
+ raise ConfigurationError("No API key. Pass api_key or set PINGLOOM_API_KEY.")
46
+ self.base_url = url.rstrip("/")
47
+ self.timeout = timeout
48
+ self.max_retries = max(0, max_retries)
49
+ self.backoff = backoff
50
+ self._sleep = _sleep
51
+
52
+ def __repr__(self) -> str:
53
+ return f"PingLoom(api_key={mask_key(self._api_key)!r}, base_url={self.base_url!r})"
54
+
55
+ # -- public API ---------------------------------------------------------------------------
56
+
57
+ def send(
58
+ self,
59
+ title: str,
60
+ body: Optional[str] = None,
61
+ channel: Optional[str] = None,
62
+ priority: Optional[str] = None,
63
+ data: Optional[Mapping[str, Any]] = None,
64
+ action_url: Optional[str] = None,
65
+ idempotency_key: Optional[str] = None,
66
+ ) -> str:
67
+ """Send a notification and return the event ID.
68
+
69
+ Without `idempotency_key` a random one is generated per call so that automatic retries cannot create
70
+ duplicates. Derive your own key from the business event (for example "order-1042-paid") when you also
71
+ want repeated calls of your own to be safe.
72
+ """
73
+ payload: Dict[str, Any] = {"title": title}
74
+ for name, value in (
75
+ ("body", body),
76
+ ("channel", channel),
77
+ ("priority", priority),
78
+ ("data", dict(data) if data is not None else None),
79
+ ("action_url", action_url),
80
+ ):
81
+ if value is not None:
82
+ payload[name] = value
83
+ key = idempotency_key or uuid.uuid4().hex
84
+ result = self._request("POST", "/v1/notifications", payload, {"Idempotency-Key": key})
85
+ return str(result["event_id"])
86
+
87
+ def status(self, event_id: str) -> Dict[str, Any]:
88
+ """Read an event back. The key needs the events:read scope."""
89
+ if not event_id or "/" in event_id:
90
+ raise ValueError("event_id must be a non-empty event ID")
91
+ return self._request("GET", f"/v1/notifications/{urllib.parse.quote(event_id, safe='')}", None, {})
92
+
93
+ # -- internals ----------------------------------------------------------------------------
94
+
95
+ def _request(self, method: str, path: str, payload: Optional[dict], extra: Dict[str, str]) -> Dict[str, Any]:
96
+ headers = {
97
+ "Authorization": f"Bearer {self._api_key}",
98
+ "Accept": "application/json",
99
+ "User-Agent": f"pingloom-python/{__version__}",
100
+ **extra,
101
+ }
102
+ raw = None
103
+ if payload is not None:
104
+ raw = json.dumps(payload).encode("utf-8")
105
+ headers["Content-Type"] = "application/json"
106
+ attempt = 0
107
+ while True:
108
+ outcome = self._once(method, path, raw, headers)
109
+ if not isinstance(outcome, PingLoomError):
110
+ return outcome
111
+ if attempt >= self.max_retries or not _retryable(outcome):
112
+ raise outcome
113
+ self._sleep(self._delay(attempt, outcome))
114
+ attempt += 1
115
+
116
+ def _once(self, method: str, path: str, raw: Optional[bytes], headers: Dict[str, str]) -> Any:
117
+ req = urllib.request.Request(self.base_url + path, data=raw, method=method, headers=headers)
118
+ try:
119
+ with urllib.request.urlopen(req, timeout=self.timeout) as res: # noqa: S310 - the scheme is the caller's
120
+ return _parse(res.read())
121
+ except urllib.error.HTTPError as e:
122
+ return self._http_error(e)
123
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as e:
124
+ return NetworkError(scrub(f"Could not reach PingLoom: {getattr(e, 'reason', e)}", self._api_key))
125
+
126
+ def _http_error(self, e: urllib.error.HTTPError) -> PingLoomError:
127
+ body = _parse(e.read(), strict=False)
128
+ env = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), dict) else {}
129
+ code = str(env.get("code") or "UNKNOWN")
130
+ message = scrub(str(env.get("message") or e.reason or f"HTTP {e.code}"), self._api_key)
131
+ retry_after = _float(e.headers.get("Retry-After")) if e.headers else None
132
+ cls = error_for(e.code, code)
133
+ return cls(
134
+ message,
135
+ status=e.code,
136
+ code=code,
137
+ request_id=env.get("request_id"),
138
+ details=env.get("details") if isinstance(env.get("details"), list) else None,
139
+ retry_after=retry_after,
140
+ )
141
+
142
+ def _delay(self, attempt: int, error: PingLoomError) -> float:
143
+ if error.retry_after is not None:
144
+ return min(max(error.retry_after, 0.0), _RETRY_CAP_SECONDS)
145
+ return min(self.backoff * (2**attempt) * (0.5 + random.random() / 2), _RETRY_CAP_SECONDS)
146
+
147
+
148
+ def _retryable(error: PingLoomError) -> bool:
149
+ if error.status == 0:
150
+ return not isinstance(error, ConfigurationError) # network error
151
+ if error.status == 429:
152
+ return error.code != "QUOTA_EXCEEDED"
153
+ return error.status >= 500
154
+
155
+
156
+ def _parse(raw: bytes, strict: bool = True) -> Any:
157
+ if not raw:
158
+ return {}
159
+ try:
160
+ return json.loads(raw.decode("utf-8"))
161
+ except (ValueError, UnicodeDecodeError):
162
+ if strict:
163
+ raise PingLoomError("PingLoom returned a response that is not JSON.", code="BAD_RESPONSE") from None
164
+ return {}
165
+
166
+
167
+ def _float(v: Optional[str]) -> Optional[float]:
168
+ try:
169
+ return float(v) if v is not None else None
170
+ except ValueError:
171
+ return None
@@ -0,0 +1,93 @@
1
+ """Typed exceptions mapped from the PingLoom error envelope {"error": {"code", "message", "request_id"}}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, List, Optional
6
+
7
+
8
+ class PingLoomError(Exception):
9
+ """Base class. `status` is 0 for errors that never reached the API."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status: int = 0,
16
+ code: str = "UNKNOWN",
17
+ request_id: Optional[str] = None,
18
+ details: Optional[List[Any]] = None,
19
+ retry_after: Optional[float] = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.status = status
24
+ self.code = code
25
+ self.request_id = request_id
26
+ self.details = details or []
27
+ self.retry_after = retry_after
28
+
29
+ def __str__(self) -> str:
30
+ parts = [self.message]
31
+ if self.code != "UNKNOWN":
32
+ parts.append(f"[{self.code}]")
33
+ if self.request_id:
34
+ parts.append(f"(request {self.request_id})")
35
+ return " ".join(parts)
36
+
37
+
38
+ class ConfigurationError(PingLoomError):
39
+ """Missing API key or base URL. Raised before any request is made."""
40
+
41
+
42
+ class NetworkError(PingLoomError):
43
+ """The request could not reach the API (DNS, connection, timeout)."""
44
+
45
+
46
+ class InvalidRequestError(PingLoomError):
47
+ """400, 413, 415 or 422: fix the request. See `details`."""
48
+
49
+
50
+ class AuthenticationError(PingLoomError):
51
+ """401: the key is missing, unknown, revoked or expired."""
52
+
53
+
54
+ class PermissionDeniedError(PingLoomError):
55
+ """403: the key lacks the needed scope."""
56
+
57
+
58
+ class NotFoundError(PingLoomError):
59
+ """404."""
60
+
61
+
62
+ class ConflictError(PingLoomError):
63
+ """409: IDEMPOTENCY_CONFLICT (same key, different body) or PROJECT_ARCHIVED. Do not retry."""
64
+
65
+
66
+ class RateLimitError(PingLoomError):
67
+ """429 RATE_LIMITED. Retried automatically; raised after the retries run out."""
68
+
69
+
70
+ class QuotaExceededError(PingLoomError):
71
+ """429 QUOTA_EXCEEDED: the monthly quota is used. Never retried."""
72
+
73
+
74
+ class ServerError(PingLoomError):
75
+ """5xx. Retried with the same idempotency key; raised after the retries run out."""
76
+
77
+
78
+ def error_for(status: int, code: str) -> type:
79
+ if status == 429:
80
+ return QuotaExceededError if code == "QUOTA_EXCEEDED" else RateLimitError
81
+ if status == 401:
82
+ return AuthenticationError
83
+ if status == 403:
84
+ return PermissionDeniedError
85
+ if status == 404:
86
+ return NotFoundError
87
+ if status == 409:
88
+ return ConflictError
89
+ if status >= 500:
90
+ return ServerError
91
+ if 400 <= status < 500:
92
+ return InvalidRequestError
93
+ return PingLoomError
File without changes
@@ -0,0 +1,226 @@
1
+ import json
2
+ import threading
3
+ from http.server import BaseHTTPRequestHandler, HTTPServer
4
+
5
+ import pytest
6
+
7
+ from pingloom import (
8
+ AuthenticationError,
9
+ ConfigurationError,
10
+ ConflictError,
11
+ InvalidRequestError,
12
+ NetworkError,
13
+ PermissionDeniedError,
14
+ PingLoom,
15
+ QuotaExceededError,
16
+ RateLimitError,
17
+ ServerError,
18
+ )
19
+ from pingloom._mask import mask_key
20
+
21
+ KEY = "pl_test_abcdefghijklmnop1234"
22
+
23
+
24
+ class Fake:
25
+ """A tiny HTTP server that replays queued (status, headers, body) responses and records requests."""
26
+
27
+ def __init__(self):
28
+ self.responses = []
29
+ self.requests = []
30
+ fake = self
31
+
32
+ class H(BaseHTTPRequestHandler):
33
+ def _handle(self):
34
+ n = int(self.headers.get("Content-Length") or 0)
35
+ body = self.rfile.read(n).decode() if n else ""
36
+ fake.requests.append(
37
+ {"method": self.command, "path": self.path, "headers": dict(self.headers), "body": body}
38
+ )
39
+ status, headers, payload = fake.responses.pop(0) if fake.responses else (200, {}, {})
40
+ raw = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
41
+ self.send_response(status)
42
+ self.send_header("Content-Type", "application/json")
43
+ self.send_header("Content-Length", str(len(raw)))
44
+ for k, v in headers.items():
45
+ self.send_header(k, v)
46
+ self.end_headers()
47
+ self.wfile.write(raw)
48
+
49
+ do_GET = do_POST = _handle
50
+
51
+ def log_message(self, *a):
52
+ pass
53
+
54
+ self.server = HTTPServer(("127.0.0.1", 0), H)
55
+ self.url = f"http://127.0.0.1:{self.server.server_port}"
56
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
57
+
58
+ def close(self):
59
+ self.server.shutdown()
60
+ self.server.server_close()
61
+
62
+
63
+ @pytest.fixture
64
+ def fake():
65
+ f = Fake()
66
+ yield f
67
+ f.close()
68
+
69
+
70
+ def make(fake, **kw):
71
+ sleeps = []
72
+ c = PingLoom(KEY, fake.url, _sleep=sleeps.append, **kw)
73
+ c.sleeps = sleeps
74
+ return c
75
+
76
+
77
+ def err(status, code, message="m", **extra):
78
+ return (status, extra.pop("headers", {}), {"error": {"code": code, "message": message, "request_id": "req_1"}})
79
+
80
+
81
+ ACCEPTED = {"event_id": "01EVENT", "status": "accepted", "created_at": "x", "status_url": "/v1/notifications/01EVENT"}
82
+
83
+
84
+ def test_env_config(monkeypatch, fake):
85
+ monkeypatch.setenv("PINGLOOM_API_KEY", KEY)
86
+ monkeypatch.setenv("PINGLOOM_BASE_URL", fake.url + "/")
87
+ c = PingLoom()
88
+ assert c.base_url == fake.url
89
+
90
+
91
+ def test_missing_config_raises(monkeypatch):
92
+ monkeypatch.delenv("PINGLOOM_API_KEY", raising=False)
93
+ monkeypatch.delenv("PINGLOOM_BASE_URL", raising=False)
94
+ with pytest.raises(ConfigurationError):
95
+ PingLoom(base_url="http://x")
96
+ assert PingLoom(api_key=KEY).base_url == "https://api.pingloom.dev"
97
+
98
+
99
+ def test_send_returns_event_id_and_sends_fields(fake):
100
+ fake.responses.append((202, {}, ACCEPTED))
101
+ c = make(fake)
102
+ eid = c.send(
103
+ "Hi", body="b", channel="ops", priority="high", data={"a": 1}, action_url="https://x.y", idempotency_key="k1"
104
+ )
105
+ assert eid == "01EVENT"
106
+ r = fake.requests[0]
107
+ assert r["method"] == "POST" and r["path"] == "/v1/notifications"
108
+ assert r["headers"]["Authorization"] == f"Bearer {KEY}"
109
+ assert r["headers"]["Idempotency-Key"] == "k1"
110
+ assert json.loads(r["body"]) == {
111
+ "title": "Hi",
112
+ "body": "b",
113
+ "channel": "ops",
114
+ "priority": "high",
115
+ "data": {"a": 1},
116
+ "action_url": "https://x.y",
117
+ }
118
+
119
+
120
+ def test_send_omits_unset_fields_and_generates_key(fake):
121
+ fake.responses.append((202, {}, ACCEPTED))
122
+ make(fake).send("Only title")
123
+ r = fake.requests[0]
124
+ assert json.loads(r["body"]) == {"title": "Only title"}
125
+ assert len(r["headers"]["Idempotency-Key"]) >= 16
126
+
127
+
128
+ def test_status(fake):
129
+ fake.responses.append((200, {}, {"event_id": "01EVENT", "status": "provider_accepted"}))
130
+ out = make(fake).status("01EVENT")
131
+ assert out["status"] == "provider_accepted"
132
+ assert fake.requests[0]["method"] == "GET" and fake.requests[0]["path"] == "/v1/notifications/01EVENT"
133
+
134
+
135
+ def test_retries_5xx_with_same_idempotency_key(fake):
136
+ fake.responses += [err(503, "INTERNAL_ERROR"), err(500, "INTERNAL_ERROR"), (202, {}, ACCEPTED)]
137
+ c = make(fake)
138
+ assert c.send("x") == "01EVENT"
139
+ keys = {r["headers"]["Idempotency-Key"] for r in fake.requests}
140
+ assert len(fake.requests) == 3 and len(keys) == 1
141
+ assert len(c.sleeps) == 2
142
+
143
+
144
+ def test_rate_limit_honours_retry_after(fake):
145
+ fake.responses += [err(429, "RATE_LIMITED", headers={"Retry-After": "2"}), (202, {}, ACCEPTED)]
146
+ c = make(fake)
147
+ c.send("x")
148
+ assert c.sleeps == [2.0]
149
+
150
+
151
+ def test_gives_up_after_max_retries(fake):
152
+ fake.responses += [err(500, "INTERNAL_ERROR")] * 5
153
+ c = make(fake, max_retries=2)
154
+ with pytest.raises(ServerError) as e:
155
+ c.send("x")
156
+ assert len(fake.requests) == 3
157
+ assert e.value.status == 500 and e.value.request_id == "req_1"
158
+
159
+
160
+ def test_quota_is_not_retried(fake):
161
+ fake.responses += [err(429, "QUOTA_EXCEEDED")]
162
+ with pytest.raises(QuotaExceededError):
163
+ make(fake).send("x")
164
+ assert len(fake.requests) == 1
165
+
166
+
167
+ @pytest.mark.parametrize(
168
+ "status,code,cls",
169
+ [
170
+ (401, "INVALID_API_KEY", AuthenticationError),
171
+ (403, "INSUFFICIENT_SCOPE", PermissionDeniedError),
172
+ (409, "IDEMPOTENCY_CONFLICT", ConflictError),
173
+ (422, "VALIDATION_ERROR", InvalidRequestError),
174
+ (413, "PAYLOAD_TOO_LARGE", InvalidRequestError),
175
+ (429, "RATE_LIMITED", RateLimitError),
176
+ ],
177
+ )
178
+ def test_error_mapping(fake, status, code, cls):
179
+ fake.responses += [err(status, code)] * 5
180
+ with pytest.raises(cls) as e:
181
+ make(fake, max_retries=0).send("x")
182
+ assert e.value.code == code and e.value.status == status
183
+ if status != 429:
184
+ assert len(fake.requests) == 1
185
+
186
+
187
+ def test_validation_details_kept(fake):
188
+ fake.responses.append(
189
+ (
190
+ 422,
191
+ {},
192
+ {
193
+ "error": {
194
+ "code": "VALIDATION_ERROR",
195
+ "message": "bad",
196
+ "request_id": "r",
197
+ "details": [{"field": "title"}],
198
+ }
199
+ },
200
+ )
201
+ )
202
+ with pytest.raises(InvalidRequestError) as e:
203
+ make(fake).send("")
204
+ assert e.value.details == [{"field": "title"}]
205
+
206
+
207
+ def test_network_error_retried_then_raised():
208
+ sleeps = []
209
+ c = PingLoom(KEY, "http://127.0.0.1:1", max_retries=2, _sleep=sleeps.append, timeout=1)
210
+ with pytest.raises(NetworkError):
211
+ c.send("x")
212
+ assert len(sleeps) == 2
213
+
214
+
215
+ def test_key_never_in_error_text_or_repr(fake):
216
+ fake.responses.append(err(401, "INVALID_API_KEY", message=f"bad key {KEY}"))
217
+ c = make(fake)
218
+ with pytest.raises(AuthenticationError) as e:
219
+ c.send("x")
220
+ assert KEY not in str(e.value) and KEY not in repr(c)
221
+ assert "pl_test_****1234" in str(e.value)
222
+
223
+
224
+ def test_mask_key():
225
+ assert mask_key("pl_live_abcdefghijklmnop9876") == "pl_live_****9876"
226
+ assert mask_key("short") == "****"