🔴Those values were set during debugging on 2026-09-01 and have not been reviewed
since. They are "whatever stopped getting in the way", not "what a real client needs".
They should be derived from measured client behaviour and then written down with the
reasoning, in either direction — they may well be too high now.
The default that actually caused the problem
FN_BUNDLE_RATE_TARGET defaults to 6 per hour, applied per requester-target pair at Routes.swift:273. On 2026-09-01 the owner hit "Too many attempts. Try again in about 49
minutes" on Android during ordinary testing.
The subtlety worth designing around: /e2e/keyspops a one-time prekey, so the limit
guards a real resource rather than being arbitrary. But sendPlaintext already avoids it —
it calls listDevices (which consumes nothing) and only fetches full bundles when some
device has no session. So a healthy client should approach this limit rarely, and a client
that keeps hitting it is usually a symptom of something else: sessions not persisting,
ratchets being dropped, repeated re-establishment. The limit was doing its job as a
smoke alarm; the fire was elsewhere.
What tuning should actually cover
Measure first. Instrument how often a normal client hits each limit across a real
conversation, a group, a call, and a sign-in. Set the numbers from that.
Separate the smoke alarm from the ceiling. A client hitting the bundle limit is
worth an alert to us; it should probably not be worth blocking the user. Consider
logging loudly and admitting the request, with the hard block much higher.
Burst then sustain. A fixed hourly bucket punishes a legitimate burst — starting a
group, or a first message to several new contacts — while a token bucket with a small
burst allowance and a modest refill would not.
Never let a limit break an established conversation. Sending to a contact with a
live session should be effectively unlimited from the user's point of view; the
scarce resource is prekeys, and an existing session needs none.
The message people see.RelayException.RateLimited already carries Retry-After
and Android turns it into "about N minutes", which is good. Check the iOS wording
matches, and that it never appears for a case the user cannot act on.
Done looks like
Each limit has a written justification, a measured headroom against real client behaviour,
and a decision about whether exceeding it should warn us or stop the user. The .env values
either match the defaults or the divergence is explained.
⚠️ Freeze in effect — measure and propose, do not change the live values.
Related: #16 (security review) — abuse resistance is the other half of this trade.
**Ask:** tune the rate limits so ordinary conversation never hits them. Nobody
swapping messages back and forth should be told to wait an hour.
## Current state — and it is not a considered state
Live on production:
```
FN_BUNDLE_RATE=2000 (code default: 120)
FN_BUNDLE_RATE_TARGET=600 (code default: 6)
FN_SEND_RATE=6000 (code default: 600)
```
🔴 **Those values were set during debugging on 2026-09-01 and have not been reviewed
since.** They are "whatever stopped getting in the way", not "what a real client needs".
They should be derived from measured client behaviour and then written down with the
reasoning, in either direction — they may well be too high now.
## The default that actually caused the problem
`FN_BUNDLE_RATE_TARGET` defaults to **6 per hour**, applied per requester-target pair at
`Routes.swift:273`. On 2026-09-01 the owner hit *"Too many attempts. Try again in about 49
minutes"* on Android during ordinary testing.
The subtlety worth designing around: `/e2e/keys` **pops a one-time prekey**, so the limit
guards a real resource rather than being arbitrary. But `sendPlaintext` already avoids it —
it calls `listDevices` (which consumes nothing) and only fetches full bundles when some
device has no session. So a healthy client should approach this limit rarely, and a client
that keeps hitting it is usually a symptom of something else: sessions not persisting,
ratchets being dropped, repeated re-establishment. **The limit was doing its job as a
smoke alarm; the fire was elsewhere.**
## What tuning should actually cover
1. **Measure first.** Instrument how often a normal client hits each limit across a real
conversation, a group, a call, and a sign-in. Set the numbers from that.
2. **Separate the smoke alarm from the ceiling.** A client hitting the bundle limit is
worth an alert to us; it should probably not be worth blocking the user. Consider
logging loudly and admitting the request, with the hard block much higher.
3. **Burst then sustain.** A fixed hourly bucket punishes a legitimate burst — starting a
group, or a first message to several new contacts — while a token bucket with a small
burst allowance and a modest refill would not.
4. **Never let a limit break an established conversation.** Sending to a contact with a
live session should be effectively unlimited from the user's point of view; the
scarce resource is prekeys, and an existing session needs none.
5. **The message people see.** `RelayException.RateLimited` already carries `Retry-After`
and Android turns it into "about N minutes", which is good. Check the iOS wording
matches, and that it never appears for a case the user cannot act on.
## Done looks like
Each limit has a written justification, a measured headroom against real client behaviour,
and a decision about whether exceeding it should warn us or stop the user. The `.env` values
either match the defaults or the divergence is explained.
⚠️ Freeze in effect — measure and propose, do not change the live values.
Related: #16 (security review) — abuse resistance is the other half of this trade.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Ask: tune the rate limits so ordinary conversation never hits them. Nobody
swapping messages back and forth should be told to wait an hour.
Current state — and it is not a considered state
Live on production:
🔴 Those values were set during debugging on 2026-09-01 and have not been reviewed
since. They are "whatever stopped getting in the way", not "what a real client needs".
They should be derived from measured client behaviour and then written down with the
reasoning, in either direction — they may well be too high now.
The default that actually caused the problem
FN_BUNDLE_RATE_TARGETdefaults to 6 per hour, applied per requester-target pair atRoutes.swift:273. On 2026-09-01 the owner hit "Too many attempts. Try again in about 49minutes" on Android during ordinary testing.
The subtlety worth designing around:
/e2e/keyspops a one-time prekey, so the limitguards a real resource rather than being arbitrary. But
sendPlaintextalready avoids it —it calls
listDevices(which consumes nothing) and only fetches full bundles when somedevice has no session. So a healthy client should approach this limit rarely, and a client
that keeps hitting it is usually a symptom of something else: sessions not persisting,
ratchets being dropped, repeated re-establishment. The limit was doing its job as a
smoke alarm; the fire was elsewhere.
What tuning should actually cover
conversation, a group, a call, and a sign-in. Set the numbers from that.
worth an alert to us; it should probably not be worth blocking the user. Consider
logging loudly and admitting the request, with the hard block much higher.
group, or a first message to several new contacts — while a token bucket with a small
burst allowance and a modest refill would not.
live session should be effectively unlimited from the user's point of view; the
scarce resource is prekeys, and an existing session needs none.
RelayException.RateLimitedalready carriesRetry-Afterand Android turns it into "about N minutes", which is good. Check the iOS wording
matches, and that it never appears for a case the user cannot act on.
Done looks like
Each limit has a written justification, a measured headroom against real client behaviour,
and a decision about whether exceeding it should warn us or stop the user. The
.envvalueseither match the defaults or the divergence is explained.
⚠️ Freeze in effect — measure and propose, do not change the live values.
Related: #16 (security review) — abuse resistance is the other half of this trade.
mentioned in issue #21
mentioned in issue flamenet-messenger-ios#13