feat: events metering and quotas (PUT-1683) (#3685)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

* feat: events metering and quotas (PUT-1683)

* fix: drop the standing subscription charge and price single deliveries at 100 µ¢

An idle durable row costs nothing worth billing; the plan quotas bound how
many an account holds. Removing the daily line also removes the global
day-claim, the whole-table scan and the timer it rode on.

* test: stop asserting on documentation pages

The limits test read rate-limits-and-quotas.md and grepped it for numbers,
so every rewording of the page failed the backend suite. Docs are kept in
step by the PR and checked in review; AGENTS.md now says so.
This commit is contained in:
Daniel Salazar
2026-09-03 01:39:07 -07:00
committed by GitHub
parent 41b837b398
commit 72011fa6ed
20 changed files with 1643 additions and 84 deletions
+30 -3
View File
@@ -159,15 +159,25 @@ Over these, **the share still succeeds** — only the announcement is dropped. T
One write can reach many subscriptions, so events are bounded on both halves: how much you may register, and how much any one event may turn into.
Durable subscriptions are the ones that outlive a connection, so they are the half that varies by plan:
| Limit | Paid | Free | Anonymous |
| ----------------------------------------- | ---- | ---- | --------- |
| Durable subscriptions per account | 500 | 100 | — |
| Durable subscriptions per app, per account | 100 | 25 | — |
A temporary (anonymous) account cannot create durable subscriptions at all — `subscribe` fails with `events_durable_requires_account`, and session subscriptions, which live and die with the connection, are the surface it has. Past either cap the call fails with `events_subscription_limit`; unsubscribing frees a slot immediately.
| Limit | All accounts |
| -------------------------------------------- | ------------ |
| Subscriptions per connection | 50 |
| Durable subscriptions per account | 500 |
| `subscribe` / `unsubscribe` calls per minute | 60 |
| Subscription listings per minute | 120 |
| Matched subscriptions per event | 50 |
| Filter evaluations per event | 200 |
| Deliveries per minute, per subscription | 600 |
| Broadcast deliveries per minute, per subscription | 600 |
| `single` deliveries per minute, per subscription | 120 |
| Handler invocations per minute, per app | 60 |
| Acknowledgements per minute | 600 |
| Undelivered deliveries per subscription | 10,000 |
| Undelivered deliveries per *suspended* subscription | 100 |
@@ -184,7 +194,7 @@ One write can reach many subscriptions, so events are bounded on both halves: ho
Subscriptions come in two kinds. A **session** subscription lives with the connection that made it: it is dropped when the connection closes, and a reconnecting client subscribes again. A **durable** subscription outlives every connection — it is created over the API, listed and revoked from the account, and keeps delivering until you remove it or it expires.
The 51st subscription on one connection, and the 501st durable subscription on one account, both fail with `events_subscription_limit`. Over the call budget, `subscribe` and `unsubscribe` fail with `too_many_requests`. Subscribing to something you cannot read fails with `subject_does_not_exist` — the same answer as subscribing to something that is not there, so the call cannot be used to find out which.
The 51st subscription on one connection, and the durable subscription past your plan's cap, both fail with `events_subscription_limit`. Over the call budget, `subscribe` and `unsubscribe` fail with `too_many_requests`. Subscribing to something you cannot read fails with `subject_does_not_exist` — the same answer as subscribing to something that is not there, so the call cannot be used to find out which.
A durable subscription may carry a `context`: JSON that is stored with it and handed to its handler on every delivery, capped at a hard **4 KB** and rejected over that with `events_context_too_large` — client-side, before the request. It is stored in plaintext and read only on the delivery path; listings return its **key names and a content hash**, never its values. For anything larger, store it in a file and put the path in `context`. An app sees and revokes only the subscriptions it created; a session acting for the account sees them all, including ones left behind by an app that has since been removed.
@@ -215,6 +225,23 @@ A **background delivery** — one that runs your app's handler with nobody there
A `single` subscription is delivered to exactly one consumer, which has **30 seconds** to acknowledge each delivery before it is offered again — twice to a connected client, then to the subscription's handler. Until it is acknowledged it is held for you, so a consumer that is away is a backlog that grows: **10,000** undelivered deliveries per subscription, after which the oldest are dropped and one `gap` marker with `reason: 'backlog_overflow'` takes their place. Each region also holds at most **1,000,000** undelivered deliveries across every subscription it serves, and sheds the oldest first — with the same marker — before it reaches that. A redelivery after a missed acknowledgement is normal and expected: deliveries are at-least-once, `event.id` is stable across them, and a handler that runs twice on the same id should do nothing the second time.
Both per-minute delivery budgets are spent per subscription and answered with a `gap` marker carrying `reason: 'delivery_rate_limit'` rather than an error. The handler budget is different: a delivery that arrives when its app has spent the minute's invocations is **not** failed and does not count as a handler failure — it stays owed and goes out on a later attempt.
#### What events cost
Deliveries are metered to the **subscription's holder** — your data, your subscriptions, your bill. A subscription that sits idle costs nothing; the plan quotas above are what bound how many you can hold.
| Line | Rate | Counted per |
| --------------------------- | -------------- | ------------------------------- |
| `events:delivery:broadcast` | 10 µ¢ | delivered event |
| `events:delivery:single` | 100 µ¢ | delivered event |
A `single` costs more because it is leased and acknowledged; a broadcast copy is a socket write. Handler runs bill separately through the usual worker path.
Only deliveries that actually happen are billed. An event a filter excluded, several writes the 250 ms window collapsed into one, a delivery a permission re-check stopped, and every `gap` marker are all free — a marker says something was lost, and charging for the loss would be charging you twice. Session subscriptions are billed at the broadcast rate like any other.
Deliveries stop when the holder's balance runs out: the subscription is suspended with `suspendedReason: 'no_credit'`, the holder is notified, and nothing further is metered against it. What it was owed is held for **1 hour**. Restoring the balance resumes it — checked periodically rather than the instant a payment lands, so allow a few minutes after topping up.
### Peer connections
| Limit | Paid | Free | Anonymous |