Skip to content

Findings: the front lane's rate limit is a size ceiling, and a session grows into it #204

Description

@jerome-queck

Findings: the front lane's rate limit is a size ceiling, and a session grows into it

Not a request for a fix. This is what a morning of failures measured, written down while the evidence is still in the log, plus what the six pull requests around it taught. The Owner decides what, if anything, to do with each.

The headline

Groq's free tier is 8,000 tokens per minute, and one Syrax turn is bigger than that. It is not a rate limit in any useful sense — waiting does not help, because a single request exceeds the whole minute's budget:

413 Request too large for model `openai/gpt-oss-120b` ... service tier `on_demand`
on tokens per minute (TPM): Limit 8000, Requested 8831

Measured across the morning: syrax-gemini/gemini-3.5-flash-lite took 313 calls with zero 429s. syrax-groq/openai/gpt-oss-120b took 62 calls with 13, every one of them a 413 dressed as a 429.

Finding 1 — a retry that grows cannot converge on a size limit

The runtime answers a 413 with rate-limit same-model retry 1/3 … delayMs=10000, then 20s, then 30s. Each retry appends the previous failure to the session, so each attempt is larger than the one that just failed:

firing attempt 1 attempt 2 attempt 3 attempt 4
05:43 8631 8631 8659 8687
06:13 8715 8743 8771 8799
07:13 8939 8967 8995 9023
08:43 9275 9303 9331 9359

+28 tokens per retry, +112 per firing, monotonic for three hours. Backoff is the right answer to a rate limit and the wrong one to a size limit: the only thing that shrinks the request is not sending the failures back.

Finding 2 — a session grows by heartbeat, unprompted

Something pokes every chat on a 30-minute cadence (observed at :13:53 and :43:53, all four agents firing together) and each poke lands in the session as a turn. Counted in the trajectories:

general/5a043b53….trajectory.jsonl      545 heartbeats
general/950b9bac….trajectory.jsonl      450
general/01c35ae3….trajectory.jsonl      129
academic/c29de0ed….trajectory.jsonl      42

Nothing in openclaw.json configures this, so it is a runtime default. It is the mechanism behind "my chat gets slower and then breaks": a session nobody has reset accumulates hundreds of turns that no one sent.

/new is currently the only lever, and its effect is dramatic — it is what stopped the runaway above at 08:49.

Finding 3 — context windows are not the constraint; the per-minute budget is

rung context window maxTokens
gemini-3.5-flash-lite 1,048,576 8192
ministral-3b-latest 131,072 1024
openai/gpt-oss-120b 131,072 1024

No turn is anywhere near a 131k context. The binding limit is Groq's 8k/minute, which is two orders of magnitude smaller than the window the same model advertises.

Unverified but worth checking: maxTokens: 1024 on two of the three front rungs may be its own bug. The shortlist tool call that #202 removed had eleven buttons carrying opaque tokens; a call like that could plausibly exceed a 1024-token output budget and arrive truncated, which is indistinguishable from the malformed calls seen. Nothing here measured it.

Finding 4 — the standing prompt is a floor under every turn

chat AGENTS.md rough tokens tools
academic 5347 B ~1340 17
general 4533 B ~1130 10
system 3399 B ~850 7
media 1667 B ~420 4

Plus each tool's own schema and description, which for the search unit are long. ADR-0011 already says every line is paid on every turn; what is new is that against an 8k/minute ceiling the floor is a material fraction of the budget before the Owner has typed anything.

Finding 5 — a pin silently removes the fallback chain

/model syrax-groq/… collapses the lane to one candidate. The log records it plainly:

"attempt": 1, "total": 1, "fallbackConfigured": false

Offered as a workaround during an outage, it made things strictly worse: the pinned rung was the one with the 8k cap, and pinning removed the two rungs that would have answered. Nothing warns about this, in the chat or in /status.

Finding 6 — a provider finish_reason is not a failover reason

gemini-3.5-flash-lite aborting with function_call_filter: MALFORMED_FUNCTION_CALL produced failoverReason: null and no fallback decision at all — the chain was never consulted. openclaw classifies failover from transport signals (status codes, timeouts); a provider that returns 200 and then refuses in its finish reason falls outside that. Rate limits do route correctly (failoverReason: "rate_limit"), so this is a gap rather than a broken router.

A malformed call that fell through to the next rung would have been an annoyance instead of a morning-long outage.

What the six pull requests taught, which is not in any of them

  • An instruction change does not take effect in a running session. Three fixes looked like failures because the model was copying its own earlier tool calls out of the context window. In-context examples beat instructions, reliably. Every instruction change needs /new before it can be judged, and that is written down nowhere.
  • A test that scripts a tool call proves the surface, never the model. A shortlist's numbered list is dropped, because a text block loses to message #198 shipped an instruction describing one shape and a test scripting another; the test stayed green while the chat was broken. The scripted arguments must be the arguments the instruction asks for, or the test measures a shape nothing produces.
  • Prose is not a schema. Three wordings produced three different malformed calls. What fixed the schema errors was showing the literal JSON. What fixed the rest was not asking the model to build the structure at all (ADR-0033).
  • The suite that matters only runs on the mini, so a red test can sit on main indefinitely — The stand-down gateway test reads an already-read deployment #196 was exactly that, and CI never sees it.

Open questions for triage

  1. Is Groq worth keeping in the front chain at 8k TPM, given one ordinary turn exceeds it? It is a rung that cannot answer.
  2. Should a 413 stop retrying rather than back off — and should the retry drop the failures it is accumulating?
  3. Should sessions be trimmed or reset on a schedule, given heartbeats grow them unprompted?
  4. Should a pin warn that it removes the fallback chain?
  5. Is maxTokens: 1024 correct for the two rungs that carry it?
  6. Is finding 6 worth reporting upstream, or pinning a runtime that handles it?

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    decisionA question to be settled and recorded as an ADRready-for-humanRequires human implementation

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions