Skip to content

Commit ce70876

Browse files
docs(error-handling): QUOTA_EXCEEDED has one producer, the ObjectOS agent chat route (#20220)
Fixes #19958 Clause-②: no Docs only. One section of `content/docs/protocol/kernel/error-handling.mdx` is rewritten: the `QUOTA_EXCEEDED` entry. No runtime, spec or error-code-ledger change. `QUOTA_EXCEEDED` stays registered, as the narrowed ruling on #17707 (`5807013768`) keeps it. ## What the page said, and what it says now Removed (it was false): > **Envelope:** none — no producer emits it > > **Not emitted today.** `QUOTA_EXCEEDED` is a registered member of the standard error-code catalog (…), but no ObjectStack producer emits it. No response carries this code, and none carries a quota `details` bag — do not write a client branch against it. Added (each sentence rests on a measurement below): > **Envelope:** nested — emitted by one route only, the ObjectOS agent chat route, and only to a JSON-mode request > > **One producer.** `POST /api/v1/ai/agents/:agentName/chat` answers `QUOTA_EXCEEDED` when the deployment has switched on its per-user daily chat-turn cap and the calling user has spent that cap. […] No other route emits this code. > > **JSON mode only.** The route streams by default. A request whose `stream` flag is absent or `true` gets the refusal as an ordinary assistant text message on HTTP 200, and no error code reaches it. Only a request with `stream: false` gets the 429 body below. > > `error.details.resetAt` is an ISO-8601 timestamp: the moment the cap lifts. It is the only recovery time the response carries. The route sets no `Retry-After` header and sends no `retryAfterSeconds`. Kept as it was: the SMS sentence (`the SMS daily quota answers TOO_MANY_REQUESTS`) and the pointer to `RATE_LIMIT_EXCEEDED` for request pacing. The SMS sentence still holds on `main`: `packages/services/service-sms/src/sms-daily-quota.ts:85` is `SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS'`. The only change there is its first word, "Quota enforcement that does exist" → "Other quota enforcement". ## The producer, measured (cloud `main` `48d70663`) The producer and reader are cited here, not in the doc, following the dispatch's route. - `packages/service-ai/src/routes/agent-routes.ts:572-575`: `return sendError(429, 'QUOTA_EXCEEDED', message, { details: { resetAt: decision.resetAt }, category: 'rate_limit' })`. - `agent-routes.ts:527`: `const wantStreamMode = body.stream !== false;`. At `:552-565` a streaming request gets `status: 200` with the copy as one `text-delta` part, so there is no code on that path. - `packages/service-ai/src/routes/envelope.ts:188-198`: `sendError` writes `{ success: false, error: { code, message, httpStatus: status, ...extra } }`. - `packages/service-ai/src/plugin.ts:1154-1159`: the gate exists only when the deployment sets its daily-turn knob to a positive number, and the only implementation wired is `DailyMessageQuota`. Its refusal always sets `resetAt` (`quota/agent-chat-quota.ts:92-96`). - No `Retry-After` on this path. `sendError` returns status and body only, and no cloud writer adds that header to this route. - Pinned in cloud by `packages/service-ai/src/__tests__/agent-error-envelope.conformance.test.ts:300-315` (status 429, code, `details` equal to `{ resetAt }`). - Control: objectstack `main` has no producer of this code. `git grep QUOTA_EXCEEDED` at `e7f69dbb` gives 40 lines: docs, generated references, the registration in `packages/spec/src/api/errors.zod.ts:106`, a ledger test, a status baseline, and the SMS `SMS_QUOTA_EXCEEDED_*` constants whose value is `TOO_MANY_REQUESTS`. ## The readers, measured objectui, pin `f8a9d0fb` (current `.objectui-sha`) and `main` `25c7d584`. `tool-display.ts`, `tool-display.test.ts` and `useObjectChat.ts` are byte-identical between the two. - `packages/plugin-chatbot/src/tool-display.ts:314-326` and `:336`: `parseAiQuotaError` deliberately does NOT recognize `QUOTA_EXCEEDED`. - The 429 is routed by `isUnsentSendError` (`:404`) and `isRateLimitError` (`:423`). They key on the HTTP status tagged by `sendAwareFetch`, or on a raw-text regex. - objectui reads no field of this body: not `code`, not `details.resetAt`, not `category`. The card's "objectui branches on it (`error.details.resetAt`, `category`)" comes from a comment at `:317` that describes the producer. No read in objectui matches it. - objectui's chat defaults to `streamingEnabled = true` (`useObjectChat.ts:768`, sent as `stream` at `:931`). Its default path therefore receives the in-band text on HTTP 200. The 429 branch is reached only when a chatbot schema sets `streamingEnabled: false`, and it is pinned by fixture (`tool-display.test.ts:365-390`). `@objectstack/client`, this repo at `e7f69dbb`: - `client.ai.agents.chat()` sends `stream: false` (`packages/client/src/index.ts:6648-6653`). It throws an error carrying `code`, `category`, `details` and `httpStatus` (from `res.status`) (`:7376-7405`). - `chatStream()` sends `stream: true` (`:6662-6667`). Field mismatches (dispatch Zone 2 item 2): - **Sent by the producer, ignored by the reader:** - objectui ignores all of `code`, `details.resetAt`, `category` and `httpStatus`, and reads `message` only through the regex probe. - The SDK reads every sent field. - **Read by a reader, not sent:** the SDK's `error.retryable`, which is `undefined` here. ## Choices this PR settled - **"ObjectOS", not "hosted".** Triage's wording was 托管的 AI agent 对话路由. The docs' own term for where this route lives is the callout in `content/docs/ai/index.mdx`: the in-product chat runtime "ships in **ObjectOS**". The section links there. - **The example shows `httpStatus: 429`.** Triage ruled 「⛔ 不要自行补充字段」. `httpStatus` is not an added field: the producer's `sendError` writes it on every body (`envelope.ts:198`), and leaving it out would misdescribe the wire. The field list under the example names only what a client acts on: `code`, `details.resetAt`, `category` and `message`. `retryAfterSeconds` and `Retry-After` are named only as absent. - **The example `message` is illustrative.** It is the English half of `DailyMessageQuota`'s copy with an example limit of 50. The real copy is bilingual, Chinese first. The doc says to display `message`, never to parse it. - **The "JSON mode only" paragraph was not in triage's text.** It is measured and it changes what a client can rely on: a streaming client never sees the code. Dispatch Zone 2 item 3 asked for the one emitting route, not a platform-wide promise. This paragraph narrows that route to its one mode. ## Acceptance notes - **`content/docs/api/error-catalog.mdx` disagrees with the rewritten section. It is not edited here (claim file surface).** - `:507-510`: the entry sends readers to "check `retryAfterSeconds`", which this producer never sends; `details.resetAt` is the field. Its cause line says "API usage quota for the current period", while the only producer is a per-user daily chat-turn cap. - The 429 row at `:913` (`rate_limit`) agrees. - Open PR #19957 has that entry only as unchanged context, and PR #20170 does not touch it. Both leave the disagreement in place. - **The page's general "Nested envelope" field list** says no route-module body carries `httpStatus`, and it does not list `category`. That is true of the writer it names, `packages/types/src/response-envelope.ts`. The ObjectOS route writes through its own `sendError`, which carries both. Not edited. - **The `fetchWithRetry` example under `RATE_LIMIT_EXCEEDED` retries any 429 by `Retry-After`.** Against this 429 it would wait zero seconds and retry. The new section warns against that. The loop under "Implement Retry Logic" keys on the code and lets `QUOTA_EXCEEDED` throw, which is right. Its comment "it is present on every 429" is inaccurate for this code's 429, but the comment sits inside the `RATE_LIMIT_EXCEEDED` branch, so behaviour is unaffected. Carrier: none. - **Card pin moved.** The card cited objectui `62597c5880`; `.objectui-sha` is now `f8a9d0fb`. The cited lines are unchanged at both. ## Verification (head `6e0bb38e`) - **Derived gate set.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gives 41 commands on the actual changed paths, identical to the dispatch lead. - **Readings.** All 41 ran, every exit code was recorded to disk before it was read, and all 41 read exit 0. - **Prerequisite refusals.** Four lines first refused with PREREQUISITE NOT MET (exit 3): `check:doc-formula-expressions`, `check:doc-security-posture`, `check:skill-examples` and `check:docs-transcript-drift`. `check:docs` was held back until spec was built. None of those refusals counted as a reading. The lines were re-run after builds under `scripts/pm/os-verify-lock.sh`: - `turbo run build` over `@objectstack/lint...`, `@objectstack/formula` and `@objectstack/spec`: VERDICT command-exit 0. - The same over `@objectstack/client-react...` and `@objectstack/client...`: VERDICT command-exit 0. `check:skill-examples` needed this second build because it refused again, on missing client declarations. - `check:skill-examples` then read "259 prose examples type-check across 3 surface(s)". - **Reconciliation.** `dispatch-gates.mjs --ran ran.list` prints: "✓ dispatch-gates --ran: 41 derived famil(ies) accounted for — 41 run, 0 NOT-MEASURED (a DERIVED zero — all 41 recorded an exit code and none of them is 3)." - **Control bytes.** `pnpm check:nul-bytes` exits 0, and the control-byte self-scan of the edited file has no hits. - **Tests.** No test and no pin was added or changed: the change is prose only, and nothing is accepted or refused differently. - **Changeset.** `skip-changeset`: 0 of 69 non-private workspace packages list a `files[]` entry reaching `content/docs`. - **Newer `main`.** `origin/main` moved to `7e7fab73`, and no commit since BASE `e7f69dbb` touches either doc. --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e8fcf55 commit ce70876

1 file changed

Lines changed: 42 additions & 7 deletions

File tree

‎content/docs/protocol/kernel/error-handling.mdx‎

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -708,15 +708,50 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3) {
708708
709709
#### `QUOTA_EXCEEDED`
710710
**HTTP Status:** 429
711-
**Meaning:** Monthly/daily quota exceeded
712-
**Envelope:** none — no producer emits it
711+
**Meaning:** A user's daily AI chat-turn cap is spent
712+
**Envelope:** nested — emitted by one route only, the ObjectOS agent chat route, and
713+
only to a JSON-mode request
714+
715+
**One producer.** `POST /api/v1/ai/agents/:agentName/chat` answers `QUOTA_EXCEEDED` when
716+
the deployment has switched on its per-user daily chat-turn cap and the calling user has
717+
spent that cap. The route belongs to the in-product chat runtime, which ships in ObjectOS
718+
rather than in the open edition (see [AI](/docs/ai)). No other route emits this code.
719+
720+
**JSON mode only.** The route streams by default. A request whose `stream` flag is absent
721+
or `true` gets the refusal as an ordinary assistant text message on HTTP 200, and no
722+
error code reaches it. Only a request with `stream: false` gets the 429 body below.
723+
`client.ai.agents.chat()` sends `stream: false` and throws an error carrying this body's
724+
`code` and `details`. `client.ai.agents.chatStream()` streams and receives the text.
725+
726+
**Example:**
727+
```json
728+
{
729+
"success": false,
730+
"error": {
731+
"code": "QUOTA_EXCEEDED",
732+
"message": "Daily AI assistant limit reached (50 messages/day). It resets at 2026-09-28T00:00:00.000Z; to continue now, contact your administrator or upgrade your plan.",
733+
"httpStatus": 429,
734+
"details": {
735+
"resetAt": "2026-09-28T00:00:00.000Z"
736+
},
737+
"category": "rate_limit"
738+
}
739+
}
740+
```
741+
742+
- `error.code` is the field to branch on.
743+
- `error.details.resetAt` is an ISO-8601 timestamp: the moment the cap lifts. It is
744+
the only recovery time the response carries. The route sets no `Retry-After` header
745+
and sends no `retryAfterSeconds`.
746+
- `error.category` is `rate_limit`.
747+
- `error.message` is the refusal copy written for the end user. Show it to the user;
748+
don't parse it.
713749
714-
**Not emitted today.** `QUOTA_EXCEEDED` is a registered member of the standard
715-
error-code catalog (`StandardErrorCode`, `packages/spec/src/api/errors.zod.ts`),
716-
but no ObjectStack producer emits it. No response carries this code, and none
717-
carries a quota `details` bag — do not write a client branch against it.
750+
Don't feed this 429 to a retry loop that only checks the status, such as the
751+
`RATE_LIMIT_EXCEEDED` example above. It has no `Retry-After` to wait on, and every
752+
retry before `resetAt` is refused again.
718753
719-
Quota enforcement that does exist answers with its own code rather than this one
754+
Other quota enforcement answers with its own code rather than this one
720755
(the SMS daily quota answers `TOO_MANY_REQUESTS`). For request pacing, the code
721756
on the wire is `RATE_LIMIT_EXCEEDED` above, whose `Retry-After` header and
722757
`retryAfterSeconds` / `resetAt` details are what a client acts on.

0 commit comments

Comments
 (0)