baukit-push
baukit-push delivers notifications to devices through a provider-neutral
PushSender port and ships one adapter for it, ExpoPushSender. Domain code
builds PushMessage values and reads PushOutcome values; nothing above the
port names Expo. Deciding who gets notified and when stays in the product.
The crate is opt-in. It is not part of the generated backend template and is not
wired into baukit_config::BaukitConfig.
The port
One call takes a whole batch. The adapter splits it into provider-sized chunks itself, so callers do not manage batching. Outcomes cover every message in the batch but arrive in no guaranteed order; match them to messages by token.
PushMessage carries a token, a title, a body, an ordered data map delivered
with the notification, and an optional channel_id that Android reads to pick a
notification channel.
Two-phase Expo delivery
Expo does not confirm delivery in the send response. /push/send answers with
one ticket per notification, meaning only that Expo accepted it. Delivery is
confirmed later through /push/getReceipts. ExpoPushSender runs both phases
per chunk, which collapses into three delivery states:
PushDeliveryStatus |
Meaning |
|---|---|
Delivered |
Expo handed the notification to APNs or FCM. |
Rejected(PushRejection) |
Expo refused it, at the ticket or receipt stage. |
Accepted |
Expo took it and has not settled a receipt yet. |
Accepted is neither success nor failure. The notification is in flight, so
never resend on it.
Rejection vocabulary
Expo's error codes map onto PushRejection:
| Expo code | PushRejection |
Retryable |
|---|---|---|
DeviceNotRegistered |
DeviceNotRegistered |
no |
MessageTooBig |
MessageTooBig |
no |
MessageRateExceeded |
MessageRateExceeded |
yes |
InvalidCredentials |
InvalidCredentials |
no |
MismatchSenderId, ProviderError |
ProviderError |
yes |
| anything else | Other(code) |
no |
An unrecognized code keeps Expo's own string rather than being dropped, so a new provider code shows up in logs instead of vanishing into a generic failure.
Pruning dead tokens
A device token stops working once the app is uninstalled or the user turns
notifications off. Expo reports that as DeviceNotRegistered. Nothing prunes
those tokens for you, and the same failures repeat on every send until you do.
Delete them after each batch:
use ;
async
is_token_dead is true only for DeviceNotRegistered. Every other rejection
describes the notification, not the token, so deleting on MessageTooBig would
throw away a working device.
Retries
A failure that stops the whole request is a PushError::Transport carrying a
baukit_http::RetryClass, the same classification the other outbound clients
use. An Expo rate limit that names a Retry-After reaches the caller as a
concrete delay:
# use Duration;
#
A malformed provider response is a PushError::InvalidResponse and is never
retryable. Per-notification refusals are not errors at all; check
PushRejection::is_retryable on each outcome instead.
Configuration
PushConfig is a Deserialize + baukit_config::Validate section a product
embeds in its own product config, which puts environment overrides on the usual
nested path (ORDERS__PUSH__BATCH_SIZE).
| Field | Default | Notes |
|---|---|---|
endpoint |
https://exp.host/--/api/v2/push/send |
Full send endpoint. The receipt URL is derived by replacing send with getReceipts. |
access_token |
empty | Secret<String>; empty means no Authorization header. |
batch_size |
100 |
1 to 100, Expo's per-request limit. |
request_timeout_ms |
8000 |
Applied to the ticket and receipt requests separately. |
PushOptions::from_config validates the whole section at once and reports every
problem together, so a bad base URL or batch size fails at startup instead of on
the first notification. In code, build options directly:
use Duration;
use ;
#
Pass the full send URL to PushOptions::new, including /push/send. This also
works for local mock servers and proxies with a different path prefix.
Expo only requires an access token when the project enforces push security. Set
one with with_access_token and it is sent as a bearer header; Debug output
redacts it.
Testing
Enable the test-support feature for FakePushSender, an in-memory recording
sender:
[]
= { = true, = ["test-support"] }
Every token delivers by default. Script exceptions per token with reject and
accept_without_receipt, or fail a whole batch with fail_with. Read back what
a service under test sent through batches, messages, outcomes, and
dead_tokens. Clones share one recording, so a clone handed to a service still
reports its sends.
The fake lives here rather than in baukit-test because it would otherwise pull
this opt-in crate into the dependencies of every product that uses the test kit.