beaconbox 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.
Files changed (38) hide show
  1. beaconbox-0.1.0/.github/workflows/ci.yml +45 -0
  2. beaconbox-0.1.0/.github/workflows/publish.yml +100 -0
  3. beaconbox-0.1.0/.gitignore +19 -0
  4. beaconbox-0.1.0/CHANGELOG.md +36 -0
  5. beaconbox-0.1.0/CONTRIBUTING.md +7 -0
  6. beaconbox-0.1.0/LICENSE +21 -0
  7. beaconbox-0.1.0/PKG-INFO +336 -0
  8. beaconbox-0.1.0/README.md +313 -0
  9. beaconbox-0.1.0/pyproject.toml +73 -0
  10. beaconbox-0.1.0/src/beaconbox/__init__.py +125 -0
  11. beaconbox-0.1.0/src/beaconbox/_core.py +248 -0
  12. beaconbox-0.1.0/src/beaconbox/_logging.py +81 -0
  13. beaconbox-0.1.0/src/beaconbox/_ops.py +196 -0
  14. beaconbox-0.1.0/src/beaconbox/_transport.py +290 -0
  15. beaconbox-0.1.0/src/beaconbox/_version.py +3 -0
  16. beaconbox-0.1.0/src/beaconbox/async_resources.py +257 -0
  17. beaconbox-0.1.0/src/beaconbox/client.py +294 -0
  18. beaconbox-0.1.0/src/beaconbox/enums.py +186 -0
  19. beaconbox-0.1.0/src/beaconbox/errors.py +145 -0
  20. beaconbox-0.1.0/src/beaconbox/models.py +737 -0
  21. beaconbox-0.1.0/src/beaconbox/py.typed +0 -0
  22. beaconbox-0.1.0/src/beaconbox/resources.py +408 -0
  23. beaconbox-0.1.0/src/beaconbox/retry.py +98 -0
  24. beaconbox-0.1.0/src/beaconbox/webhooks.py +132 -0
  25. beaconbox-0.1.0/tests/__init__.py +0 -0
  26. beaconbox-0.1.0/tests/conftest.py +187 -0
  27. beaconbox-0.1.0/tests/live/README.md +57 -0
  28. beaconbox-0.1.0/tests/live/__init__.py +0 -0
  29. beaconbox-0.1.0/tests/live/test_live.py +267 -0
  30. beaconbox-0.1.0/tests/test_async.py +237 -0
  31. beaconbox-0.1.0/tests/test_client.py +209 -0
  32. beaconbox-0.1.0/tests/test_core.py +203 -0
  33. beaconbox-0.1.0/tests/test_logging.py +243 -0
  34. beaconbox-0.1.0/tests/test_models.py +259 -0
  35. beaconbox-0.1.0/tests/test_packaging.py +119 -0
  36. beaconbox-0.1.0/tests/test_resources.py +494 -0
  37. beaconbox-0.1.0/tests/test_retry.py +239 -0
  38. beaconbox-0.1.0/tests/test_webhooks.py +137 -0
@@ -0,0 +1,45 @@
1
+ name: CI
2
+
3
+ # Unit tests only. The live suite is gated on BEACONBOX_LIVE_URL and skips itself here: it needs
4
+ # a running BeaconBox, which is exercised in the private platform repository before a release is
5
+ # cut.
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ pull_request:
10
+
11
+ jobs:
12
+ test:
13
+ name: Python ${{ matrix.python }}
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ # The versions pyproject.toml declares support for. 3.10 is the floor and is listed first
18
+ # because it is the one that breaks: it is where `tomllib` is absent and where a 3.11+ only
19
+ # syntax would surface.
20
+ matrix:
21
+ python: ['3.10', '3.11', '3.12', '3.13']
22
+ steps:
23
+ - uses: actions/checkout@v7
24
+
25
+ - name: Install uv
26
+ uses: astral-sh/setup-uv@v9.0.0
27
+ with:
28
+ enable-cache: true
29
+
30
+ - name: Set up Python ${{ matrix.python }}
31
+ run: uv python install ${{ matrix.python }}
32
+
33
+ - name: Sync deps
34
+ run: uv sync --all-extras --dev
35
+
36
+ - name: Ruff
37
+ run: |
38
+ uv run ruff check .
39
+ uv run ruff format --check .
40
+
41
+ - name: Mypy
42
+ run: uv run mypy
43
+
44
+ - name: Pytest
45
+ run: uv run pytest -q
@@ -0,0 +1,100 @@
1
+ name: Publish to PyPI
2
+
3
+ # Pushing a tag `vX.Y.Z` to this repository is what publishes a release.
4
+ #
5
+ # The distribution is `beaconbox`; the import name is `beaconbox` too.
6
+ on:
7
+ push:
8
+ tags:
9
+ - 'v*'
10
+
11
+ jobs:
12
+ publish:
13
+ name: Build + publish
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ # PyPI trusted publishing (OIDC). No API token is stored anywhere: PyPI is configured to
17
+ # trust this workflow, in this repository, and mints a short-lived credential per run.
18
+ #
19
+ # For the FIRST release the project does not exist on PyPI yet, so there is nothing to
20
+ # attach a publisher to — configure a *pending* publisher first at
21
+ # https://pypi.org/manage/account/publishing/. Its **PyPI project name is the distribution
22
+ # name, `beaconbox`** — not `beaconbox-python`, which is this repository. Getting that wrong
23
+ # fails the upload with "invalid-publisher" *after* the tag is public, because PyPI looks
24
+ # for a publisher for the project being uploaded and finds none.
25
+ id-token: write
26
+ # Spelling `permissions` at all drops every default to `none`, so checkout's read has to be
27
+ # asked for back.
28
+ contents: read
29
+
30
+ # **Deliberately no `environment:`.** An environment is worth adding for a publish job — it is
31
+ # where a required reviewer would go — but it has to be added on both sides at once: PyPI
32
+ # matches the publisher on (project, repository, workflow, environment), so naming one here
33
+ # while the publisher says "(Any)" is a second thing to keep in step for no gain today.
34
+ steps:
35
+ - uses: actions/checkout@v7
36
+
37
+ - name: Install uv
38
+ uses: astral-sh/setup-uv@v9.0.0
39
+ with:
40
+ enable-cache: true
41
+
42
+ # The version is declared once, in `_version.py`, and `pyproject.toml` reads it through
43
+ # hatchling's `dynamic = ["version"]`. So this reads the module, NOT `project.version` —
44
+ # that key does not exist in this project and looking it up raises rather than comparing.
45
+ #
46
+ # The tag is checked against it because a tag is the only part of a release nobody
47
+ # validates by running it: the wheel would build and upload happily under a number that
48
+ # disagrees with what the client reports in its User-Agent.
49
+ - name: Verify the tag matches the package version
50
+ run: |
51
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
52
+ PKG_VERSION="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' src/beaconbox/_version.py)"
53
+ if [ -z "$PKG_VERSION" ]; then
54
+ echo "::error::could not read __version__ from src/beaconbox/_version.py"
55
+ exit 1
56
+ fi
57
+ if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
58
+ echo "::error::tag $TAG_VERSION does not match __version__ $PKG_VERSION"
59
+ exit 1
60
+ fi
61
+ echo "publishing $PKG_VERSION"
62
+
63
+ - name: Sync deps
64
+ run: uv sync --all-extras --dev
65
+
66
+ # The gate runs here and not only on the pushing side, because this is the tree that gets
67
+ # uploaded. A tag can be pushed at any commit by anyone with write access.
68
+ - name: Test
69
+ run: uv run pytest -q
70
+
71
+ - name: Lint
72
+ run: |
73
+ uv run ruff check .
74
+ uv run ruff format --check .
75
+
76
+ - name: Types
77
+ run: uv run mypy
78
+
79
+ - name: Build
80
+ run: uv build
81
+
82
+ # `py.typed` reaching the wheel is the one packaging property that fails silently in a
83
+ # consumer's project: PEP 561 makes a type checker ignore every annotation in a package
84
+ # without it, with no error anywhere.
85
+ - name: Check the artefact
86
+ run: |
87
+ python - <<'PY'
88
+ import glob, sys, zipfile
89
+
90
+ wheel = glob.glob("dist/*.whl")[0]
91
+ names = zipfile.ZipFile(wheel).namelist()
92
+ if not any(n.endswith("beaconbox/py.typed") for n in names):
93
+ sys.exit(f"{wheel} ships no py.typed")
94
+ if any(n.startswith("tests/") for n in names):
95
+ sys.exit(f"{wheel} ships the test suite")
96
+ print(f"{wheel}: ok")
97
+ PY
98
+
99
+ - name: Publish
100
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,19 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+
11
+ # A lock file is for an application, not a library. This package is installed *next to* a
12
+ # consumer's own dependencies and resolved against them, so a lock committed here pins nothing
13
+ # for anybody who installs it — and hatchling puts it in the sdist, where it is noise. The PHP
14
+ # SDK gitignores composer.lock for the same reason.
15
+ #
16
+ # `uv run` writes one as a side effect, so this line is what keeps it from being committed by
17
+ # accident rather than on purpose. Tracking lock files for the SDKs would be a deliberate
18
+ # decision for both of them, not one that arrives with a stray command.
19
+ uv.lock
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
6
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0]
9
+
10
+ ### Added
11
+
12
+ - `BeaconBox` and `AsyncBeaconBox` clients, with the same 18 operations and identical signatures.
13
+ - Typed models for every response, with `py.typed`. Unknown fields remain available via `.raw`.
14
+ - Cursor pagination via `messages.iterate()`, and `async for` on the async client.
15
+ - Webhook signature verification via `beaconbox.webhooks.verify()`.
16
+ - Automatic `Idempotency-Key` on every write, reused across the SDK's own retries. Override per
17
+ call with `idempotency_key=`.
18
+ - Retries with exponential backoff and jitter on connection errors, 429 and 5xx. Configure with
19
+ `RetryPolicy`; `max_retries=0` disables them.
20
+ - `Retry-After` honoured up to `RetryPolicy.max_retry_after` (30s). Beyond it, `RateLimitError` is
21
+ raised with `retry_after` set rather than retrying.
22
+ - Skipped SMS and WhatsApp channels are reported on the result; they do not raise.
23
+ - Configurable `base_url`, `timeout`, `retry_policy`, `verify`, `max_connections` and
24
+ `user_agent_suffix`. An `httpx` client can be injected via `http_client=` instead.
25
+ - Opt-in logging on the `beaconbox` logger. Silent by default; the SDK configures nothing.
26
+ - Plain `http` is refused except on localhost.
27
+ - Redirects are not followed.
28
+ - API keys, newly minted keys and webhook endpoint secrets are excluded from `repr()`.
29
+ - Log records carry route templates (`/recipients/{email}/sms`), never interpolated paths.
30
+
31
+ ### Requirements
32
+
33
+ - Python 3.10+
34
+ - [httpx](https://www.python-httpx.org) `>=0.28.1,<1`
35
+
36
+ [0.1.0]: https://github.com/beaconbox-eu/beaconbox-python/releases/tag/v0.1.0
@@ -0,0 +1,7 @@
1
+ # Contributing
2
+
3
+ This repository is a **read-only mirror**. It is published from the BeaconBox monorepo, and
4
+ anything pushed here directly is overwritten by the next release.
5
+
6
+ Bug reports and feature requests are welcome as issues. For a code change, open an issue first and
7
+ we will apply it upstream with attribution.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BeaconBox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,336 @@
1
+ Metadata-Version: 2.5
2
+ Name: beaconbox
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for BeaconBox, order updates your customers actually receive.
5
+ Project-URL: Homepage, https://beaconbox.eu
6
+ Project-URL: Documentation, https://docs.beaconbox.eu
7
+ Project-URL: Source, https://github.com/beaconbox-eu/beaconbox-python
8
+ Project-URL: Issues, https://github.com/beaconbox-eu/beaconbox-python/issues
9
+ Author-email: BeaconBox <sdk@beaconbox.eu>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: beaconbox,eu,notifications,sms,transactional,whatsapp
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx<1,>=0.28.1
22
+ Description-Content-Type: text/markdown
23
+
24
+ # beaconbox
25
+
26
+ Official Python SDK for [BeaconBox](https://beaconbox.eu), order updates your customers actually
27
+ receive.
28
+
29
+ ```bash
30
+ pip install beaconbox
31
+ ```
32
+
33
+ Python 3.10+. One runtime dependency, [httpx](https://www.python-httpx.org), which is also the
34
+ only HTTP library where the sync and the async client share an API, so this SDK's logic is
35
+ written once rather than twice.
36
+
37
+ ## Push an update
38
+
39
+ ```python
40
+ from beaconbox import BeaconBox
41
+
42
+ client = BeaconBox() # reads BEACONBOX_API_KEY
43
+
44
+ result = client.messages.push(
45
+ recipient_email="buyer@example.com",
46
+ subject="Your order has shipped",
47
+ body="Tracking XY123456789EE. Estimated delivery Thursday.",
48
+ kind="updateable",
49
+ )
50
+
51
+ print(result.id) # 3xg39cvt8a46
52
+ ```
53
+
54
+ The customer gets an email whose link opens their inbox already signed in. No password, no
55
+ account to create.
56
+
57
+ Re-push the same `subject` with `kind="updateable"` to overwrite it in place, quietly. Add
58
+ `notify=True` to force a nudge on an update, or `obsoletes=[...]` to grey out messages this one
59
+ replaces.
60
+
61
+ ## Async
62
+
63
+ Same client, same signatures, awaited. Nothing blocks the event loop, including retry backoff.
64
+
65
+ ```python
66
+ from beaconbox import AsyncBeaconBox
67
+
68
+ async with AsyncBeaconBox() as client:
69
+ result = await client.messages.push(
70
+ recipient_email="buyer@example.com",
71
+ subject="Your order has shipped",
72
+ body="Tracking XY123456789EE.",
73
+ )
74
+
75
+ async for message in client.messages.iterate(recipient_email="buyer@example.com"):
76
+ print(message.id, message.delivery.opened)
77
+ ```
78
+
79
+ ## Two things to know before anything else
80
+
81
+ ### 1. Read the response, not the status code
82
+
83
+ A push returns **201 even when the SMS or WhatsApp message was not sent.** The update is already
84
+ in the customer's inbox and the email nudge has gone, so a paid channel that could not send
85
+ reports a reason and the request still succeeded.
86
+
87
+ ```python
88
+ from beaconbox import SkipReason
89
+
90
+ result = client.messages.push(
91
+ recipient_email="buyer@example.com",
92
+ recipient_phone="+37255550134",
93
+ subject="Your order has shipped",
94
+ body="Tracking XY123456789EE.",
95
+ channels=["sms"],
96
+ )
97
+
98
+ if result.sms and result.sms.skipped_reason == SkipReason.INSUFFICIENT_CREDIT:
99
+ # Top up, then: client.messages.resend_sms(result.id)
100
+ ...
101
+ ```
102
+
103
+ **This SDK does not raise on a skip**, deliberately. Treating one as an error is what invites a
104
+ retry, and a retry of a push that already succeeded is a second message to a real person.
105
+
106
+ ### 2. Idempotency is handled for you, and you can do better
107
+
108
+ Every write carries an `Idempotency-Key`. This SDK generates one per call and **reuses it across
109
+ its own retries**, so a connection that dies with the answer in flight cannot become a duplicate
110
+ email and a duplicate charged SMS.
111
+
112
+ Pass your own whenever you have a natural key:
113
+
114
+ ```python
115
+ client.messages.push(..., idempotency_key="order-4711-shipped")
116
+ ```
117
+
118
+ Then a retry from *anywhere*, your queue, a cron, a human clicking twice, collapses onto the same
119
+ key rather than only the retries this SDK makes internally.
120
+
121
+ ## Everything else
122
+
123
+ ```python
124
+ from beaconbox import MessagePush
125
+
126
+ # Delivery status
127
+ client.messages.get("3xg39cvt8a46")
128
+
129
+ # Every message, following cursors. A generator, so a year of history is not held in memory
130
+ for message in client.messages.iterate(recipient_email="buyer@example.com"):
131
+ ...
132
+
133
+ # Take one back: withdrawn for the recipient, any queued nudge called off, no credit spent
134
+ client.messages.retract("3xg39cvt8a46")
135
+
136
+ # Up to 100 pushes. Always 200: read result.failed, not the status code
137
+ result = client.messages.push_batch(
138
+ [
139
+ MessagePush(recipient_email="a@example.com", subject="Shipped", body="..."),
140
+ MessagePush(recipient_email="b@example.com", subject="Shipped", body="..."),
141
+ ]
142
+ )
143
+ for item in result.failures:
144
+ print(item.index, item.error_code)
145
+
146
+ # Contact details. Reads are masked: a leaked key must not dump a phone book
147
+ client.recipients.set_phone("buyer@example.com", "+37255550134")
148
+ client.recipients.clear_phone("buyer@example.com")
149
+
150
+ # The prepaid balance. One balance, shared by SMS and WhatsApp
151
+ client.credits.balance()
152
+
153
+ # Keys and webhook endpoints
154
+ client.keys.create("orders service")
155
+ client.webhook_endpoints.create("https://example.com/hooks") # empty list means every event
156
+ ```
157
+
158
+ ### Pay only for the customers the email did not reach
159
+
160
+ `escalate_if_unread_after_minutes` holds the paid channel back. The SMS is sent only if the
161
+ recipient still has not opened the message after that long, and if they open it first, nothing is
162
+ sent and nothing is charged.
163
+
164
+ ```python
165
+ client.messages.push(
166
+ recipient_email="buyer@example.com",
167
+ recipient_phone="+37255550134",
168
+ subject="Action needed on your order",
169
+ body="We could not process your payment.",
170
+ channels=["sms"],
171
+ escalate_if_unread_after_minutes=120,
172
+ )
173
+ ```
174
+
175
+ ### Erasing a recipient's WhatsApp history
176
+
177
+ ```python
178
+ receipt = client.recipients.erase_whatsapp("buyer@example.com")
179
+
180
+ # The half only you can finish: erased replies whose words may already be in *your* mailboxes.
181
+ yours = receipt.replies_a_forward_email_may_have_carried
182
+ ```
183
+
184
+ ## Verifying webhooks
185
+
186
+ ```python
187
+ import os
188
+ from beaconbox import WebhookVerificationError, WebhookEventType, webhooks
189
+
190
+
191
+ @app.post("/hooks/beaconbox")
192
+ async def hook(request):
193
+ try:
194
+ event = webhooks.verify(
195
+ await request.body(), # the RAW body, byte for byte
196
+ request.headers["X-BeaconBox-Signature"],
197
+ os.environ["BEACONBOX_WEBHOOK_SECRET"],
198
+ )
199
+ except WebhookVerificationError:
200
+ return Response(status_code=400)
201
+
202
+ if event.type == WebhookEventType.MESSAGE_BOUNCED:
203
+ ...
204
+ return {"ok": True}
205
+ ```
206
+
207
+ **Pass the raw body.** Decoding to a dict and re-encoding changes the bytes over key order and
208
+ whitespace, and the signature stops matching, in production, on a payload shaped slightly
209
+ differently from the one you tested with. The helper also checks the timestamp (which is signed,
210
+ so a captured delivery cannot be replayed) and compares in constant time.
211
+
212
+ Deliveries are **retried**, so the same `event.id` can arrive twice. Deduplicate on it.
213
+
214
+ ## Errors
215
+
216
+ Everything inherits from `beaconbox.BeaconBoxError`.
217
+
218
+ | Exception | When |
219
+ | --- | --- |
220
+ | `AuthenticationError` | 401, key missing, malformed or revoked |
221
+ | `PermissionDeniedError` | 403 |
222
+ | `InvalidRequestError` | 422, a malformed field, or a reused idempotency key with a different body |
223
+ | `ResourceMissingError` | 404, no such id. Also what another business's id looks like, deliberately |
224
+ | `ConflictError` | 409, already sent, or an identical request still in flight |
225
+ | `RateLimitError` | 429, after the SDK has already retried |
226
+ | `ServerError` | 5xx, after the SDK has already retried |
227
+ | `APIConnectionError` | no answer at all. **Not** proof the work did not happen |
228
+ | `WebhookVerificationError` | a delivery could not be proven to be ours |
229
+
230
+ Branch on `error.error_code` (a stable dotted string such as `message.not_found`), not on the
231
+ message text.
232
+
233
+ ## Configuration
234
+
235
+ ```python
236
+ from beaconbox import BeaconBox, RetryPolicy
237
+
238
+ client = BeaconBox(
239
+ api_key="bbx_live_...", # or $BEACONBOX_API_KEY
240
+ base_url="https://api.beaconbox.eu", # or $BEACONBOX_BASE_URL
241
+ timeout=30.0,
242
+ retry_policy=RetryPolicy(max_retries=2),
243
+ max_connections=20,
244
+ user_agent_suffix="acme-orders/2.1",
245
+ )
246
+ ```
247
+
248
+ Reuse one client: it holds a connection pool, and it is safe to share between threads. Close it,
249
+ or use it as a context manager.
250
+
251
+ Inside a job runner that already retries, pass `RetryPolicy(max_retries=0)` so the two schedules
252
+ do not multiply.
253
+
254
+ ### Backpressure
255
+
256
+ A connection failure, a 429 and a 5xx are retried; a 4xx is not, because sending the same wrong
257
+ request again asks the same question. Backoff is exponential with full jitter, since the failure
258
+ being absorbed is synchronised across every worker you run.
259
+
260
+ **A `Retry-After` is honoured in full, not shortened to the backoff cap.** It is the server's own
261
+ answer to when it will be ready, and retrying earlier only earns a second 429. Jitter is added *on
262
+ top* of it rather than sampled from within it — the herd is at its worst here, because every worker
263
+ that hit the same 429 was handed the same number.
264
+
265
+ If the server asks for longer than `RetryPolicy.max_retry_after` (30s by default), the SDK **stops
266
+ rather than retrying early** and raises `RateLimitError` with `retry_after` set. Blocking for the
267
+ cap and being refused anyway helps nobody; the number is what you need to schedule a real retry.
268
+
269
+ ### Concurrency
270
+
271
+ `max_connections` is a ceiling, and exceeding it does not look like one: the overflow queues for a
272
+ free connection, and a queue wait that outlives the pool timeout surfaces as `APIConnectionError`
273
+ rather than as anything mentioning a pool. If you fan out more concurrent calls than the ceiling —
274
+ `asyncio.gather` over a few hundred pushes makes that easy — raise it to match.
275
+
276
+ ## Logging
277
+
278
+ Standard library `logging`, under the `beaconbox` logger, and **silent until you ask**. The SDK
279
+ attaches a `NullHandler` and configures nothing else: no `basicConfig`, no handlers, no level on
280
+ the root logger.
281
+
282
+ ```python
283
+ import logging
284
+
285
+ logging.getLogger("beaconbox").setLevel(logging.DEBUG)
286
+ ```
287
+
288
+ | Level | What |
289
+ | --- | --- |
290
+ | `DEBUG` | every request and response, with status and elapsed time |
291
+ | `WARNING` | a retry (with reason and backoff), and giving up after the last one |
292
+
293
+ Nothing is emitted at `INFO` or above in normal operation, so a `WARNING` from this SDK always
294
+ means something went wrong.
295
+
296
+ Each record carries a `beaconbox` attribute with structured fields (`route`, `attempt`,
297
+ `status_code`, `elapsed_ms`, `idempotency_key`) for a JSON formatter.
298
+
299
+ **Nothing sensitive is ever logged.** Not the API key or any header, not request or response
300
+ bodies, not the query string, and not the interpolated URL path. A route template is logged
301
+ instead, so `/recipients/buyer@example.com/sms` appears as `/recipients/{email}/sms`. The fields
302
+ are an allow-list rather than a redaction pass, because redaction is a list of things somebody
303
+ remembered to hide and the field added next year is not on it.
304
+
305
+ ### Security defaults you cannot accidentally lose
306
+
307
+ - **Plain `http` is refused** for anything but localhost, so a misconfigured `base_url` cannot put
308
+ your API key on the wire in clear.
309
+ - **Redirects are never followed.** httpx would re-send the `Authorization` header to wherever a
310
+ redirect points.
311
+ - The key is never in a `repr`, and `NewApiKey.__repr__` redacts the minted key.
312
+ - Webhook signatures are compared in constant time, over a signed timestamp.
313
+
314
+ Need a corporate CA bundle? Pass `verify=` a path or an `ssl.SSLContext`. Need a proxy or custom
315
+ instrumentation? Pass your own `http_client=httpx.Client(...)`, and then you own closing it.
316
+
317
+ `timeout` and `verify` are **refused** alongside `http_client`, rather than silently ignored: they
318
+ are settings on the client you supplied, and accepting them while doing nothing is how somebody
319
+ discovers during an incident that the timeout they set was never applied.
320
+
321
+ ## Development
322
+
323
+ ```bash
324
+ uv venv && uv pip install -e '.[dev]'
325
+ pytest # hermetic, no network
326
+ mypy src tests
327
+ ruff check .
328
+ ```
329
+
330
+ The live suite runs against a real BeaconBox. See [`tests/live/README.md`](tests/live/README.md).
331
+
332
+ ## Licence
333
+
334
+ MIT. See [LICENSE](LICENSE).
335
+
336
+ BeaconBox is a product of BloomHarbor OÜ, a company registered in Estonia.