fix(mcp): the initialize limiter refuses correctly and tells the client nothing #20
Labels
No labels
blocked
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jlxq0/caldav-mcp#20
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Reported by Clark, who hit it while blocked by it. Filed rather than fixed: development is paused until 19:00 and the DAV server's rate limiting is separately unexplained. This goes ahead of #16 — it has an observed victim.
What happened
Julian's claude.ai session reconnected repeatedly, exhausted the initialize allowance, never reached a tool call, and concluded the window "needs real time to reset". Five failed attempts, and a wrong model of the rule at the end of them.
The limiter behaved correctly. Nothing here proposes changing it.
The numbers, read from this repository rather than relayed
InitializeLimiter::new(replenish_1_per, burst)buildsQuota::with_period(30 min).allow_burst(8). So: eight back-to-back, then one slot every 30 minutes, and four hours to refill from empty.That is worse than "the window resets", and wrong in the direction that costs the user. Someone who waits out what they believe is a 30-minute window gets exactly one session, reconnects twice, and is refused again — which reads as the rule being broken rather than obeyed.
Two buckets, keyed on
sha256(bearer)[..16]and the Logtosub. That is deliberate and it is why this caught a client minting a fresh token per session: a new bearer gets a fresh bucket, thesubbucket does not. The header comment already names this case.The defect
src/main.rs:No
Retry-After. No machine-readable class. A plain-text body whose entire actionable content is "later".A client cannot distinguish too many sessions from your request was rejected, and cannot know when to try again. So it retries, and every retry against a correct rule looks to the person behind it like the rule being broken.
The number needed is already computed and then thrown away.
governorreturnsNotUntil, which carrieswait_time_from(now);InitializeLimiter::checkmaps it to a unit struct:This is not "we cannot know when to retry". It is knowing and discarding it at the boundary.
Proposed shape, subject to review
checkreturns the wait, not a unit —Err(RateLimited { retry_after: Duration })fromNotUntil::wait_time_from.Retry-Afterwith that number: seconds until one slot exists, never the window length. The window length is the wrong number and it is precisely the one Julian inferred.{"error":"too_many_sessions","retry_after_seconds":N}. The refusal happens in HTTP middleware ahead of rmcp, so this is the layer that can carry it.To verify rather than assume, while in there
checkconsumes the bearer cell before testing thesubbucket:governor'scheck()consumes a cell on success, so a refusal caused by thesubbucket has already spent one from the bearer bucket. Reading the code that appears to make repeated attempts drain the bearer allowance faster than the rule advertises. Stated as something to test, not as a finding — the test is two attempts against asubbucket that is already empty, asserting the bearer bucket's remaining capacity.The general form, for
AGENTS.mdWhich is the day's subject pointed at a person rather than at a check: a correct refusal that carries no information is the same object as a green that carries none. Both are outcomes indistinguishable from the outcome they are not.
The entry belongs with the fix, not after it, because the next limiter written in this repo will have the same shape.
What this limiter protects, since the answer decides the number
Not Stalwart. It protects this server's own session table.
CappedSessionManagerrejectscreate_sessiononce 256 sessions are live, and the initialize limiter exists so one authenticated identity cannot reach that cap alone. Stalwart is shielded by the read and write limiters at 60 and 30 per minute, which are a different resource and unaffected by any change here. So the initialize limiter is not redundant — but its correct value is a function ofMAX_SESSIONS, and that has not been part of the discussion.The arithmetic the proposed raise implies
Raising the burst fourfold divides the flood threshold fourfold. With refill at one per minute, one identity also sustains 60 initializes an hour indefinitely, and since sessions idle out after 30 minutes its steady-state holding is roughly 30 concurrent sessions — about an eighth of the pool, per identity, without bursting at all.
That may be entirely fine: this deployment has a handful of users, not eight hundred. But it should be decided rather than arrived at, and the decision is "how many identities do we need to be safe against" rather than "what number unblocks Julian". If eight is too few,
MAX_SESSIONSmoves with the burst, and it is the same one-line environment change.One thing worth noticing while here
The refusal at the deeper guard is more useful than the one at the shallower:
That names the resource and its size. The initialize limiter, which fires first and is therefore the one a user actually meets, says only "too many MCP initialize requests; try again later". The guard nobody reached reports better than the guard everybody hits.
Ordering for the change
Per Alan, and recorded here so the sequence is not re-litigated tomorrow:
tracing::warn!on the refusal branch, first and alone. No 429 from this limiter has been directly observed — the diagnosis is inference from a logged absence. It is a good chain, and a good chain is not an observation. If the warn lands and Julian is not hitting this branch, the raise is treating the wrong thing.Retry-AfterfromNotUntil::wait_time_from, never the window length.MAX_SESSIONSinteraction above settled in the same PR.Shipping 4 without 1–3 is the same afternoon at a higher number.
Four additions from Clark, recorded here rather than left in a channel
1. The disposal branch is already closed. Clark's rule is right — a redundant limiter that has cost a user an afternoon should be removed rather than retuned — but it does not apply: this limiter does not shield Stalwart. It bounds
session::MAX_SESSIONS = 256, this server's own session table, and Stalwart is shielded separately by the read and write limiters at 60 and 30 per minute. Recorded so tomorrow does not re-open a settled question.The live branch is the other one: 32 concurrent is only cheap if a session is small, and nobody has measured one. "8 → 32 is fine" is a claim about a number that does not exist yet. Measure the per-session footprint before accepting the burst, alongside the
MAX_SESSIONSarithmetic above — eight identities at full burst fill the pool.2.
Retry-Aftercomes from the bucket, never from the constants. A value computed fromSESSION_KEEP_ALIVEand the burst drifts from the quota the first time the quota changes, and the quota changes in this same PR.NotUntil::wait_time_fromis the source. This is the same fault as the original defect pointed at its own fix: a number that is available being re-derived from something that only agrees today.3. Provoke the warn line on beta before the raise ships anywhere.
caldav-mcp-betahas logged two lines in total since08:21:50Z— verified:A 429 against that background is unmissable and costs nobody anything. This is the only cheap chance to observe the refusal rather than infer it, and the whole diagnosis currently rests on inference from a logged absence.
4. The connector's session reuse is the actual bug and survives the raise. Filed separately as #21. Twenty-four events costing twenty-four initializes means a fresh session per call, and raising the ceiling does not fix that — it moves where it bites. The issue exists so the next person meeting a limit at 32 finds the reason rather than raising it to 64.
Attribution for the PR body
The sentence, verbatim, is Clark's Infrastructure lead's, from the investigation, and is an observation rather than editorial:
That is why
rate_limited 0was true and useless across twelve hours, and it is the reasonsrc/metrics.rsgains an HTTP status counter in this change: the metric that would have shown this was the one nobody had.Order
tracing::warn!on the refusal branch, aloneRetry-Afterfrom the bucket, a distinguishable JSON-RPC error code, the numbers as environment variables, and the HTTP status metricReport the resulting pod digest to Clark as well as to Alan; Clark verifies it against the registry.
Decided, so tomorrow starts here
The raise as originally specified was a regression and would have been invisible in the diff:
It trades a limit that refuses one churning user for one that lets eight of them lock everybody out — a per-user refusal converted into global exhaustion, in the direction nobody notices, because the person it breaks is not the person who triggered it.
Rule: the burst and the pool move together or not at all.
Decision (Alan):
MAX_SESSIONSto 1024 with burst at 32, preserving today's 32-identities-to-fill ratio. Keeping the ratio the current design chose rather than inventing a new number.Conditional on the measurement below, which is now gating rather than a nicety. If a session is large enough that 1024 is not free, keep the ratio and cap on memory instead. Either way the PR states the number, because a capacity number defended by a feeling is the same object as a threshold nobody derived.
Fallback if measuring proves expensive: burst 24,
MAX_SESSIONSunchanged. Covers the 24 events exactly, takes 10 identities to fill rather than 8, and the one-per-minute refill still does the work. No pool change, no measurement.How to measure a session's footprint, cheaply
Beta is the right instrument for the same reason it is the right place for the warn line: it has logged two lines since
08:21:50Zand serves nothing, so any delta is attributable.container_memory_working_set_bytesfor theappcontainer oncaldav-mcp-beta, with no sessions live.Measuring the pod rather than summing struct sizes is deliberate: a session retains rmcp's own session state and our
SessionIdentityBindingsentry, and the pod metric captures both without me having to enumerate what I think a session holds. Enumerating is how the estimate ends up defending the number I already wanted.The result multiplies to a memory figure for 1024 that goes in the PR next to the pod's actual limit, and the decision is then arithmetic rather than judgement.
Order tomorrow
tracing::warn!on the refusal branch, aloneRetry-Afterfromwait_time_from, a distinguishable JSON-RPC error code, the numbers as environment variables, and the HTTP status counter insrc/metrics.rsThen #16.