Skip to content

fix: report Cookbot quota errors accurately instead of "busy, try again" - #86

Merged
dubadub merged 3 commits into
mainfrom
fix/cookbot-quota-error-message
Aug 11, 2026
Merged

fix: report Cookbot quota errors accurately instead of "busy, try again"#86
dubadub merged 3 commits into
mainfrom
fix/cookbot-quota-error-message

Conversation

@dubadub

@dubadub dubadub commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

A user hit Cookbot is busy right now. Please wait a moment and try again. and retried four times, each failing instantly, with the server healthy.

That message was mine, from #83, and it was wrong. The server had said the account's AI credits for the billing cycle were used up — which no amount of waiting fixes. The user retried because the message told them to.

Cause

I collapsed a whole gRPC status into a single guess. cookbot returns exactly three machine-readable reasons, and all three were flattened into misleading guidance:

Server Was shown Reality
resource_exhausted("quota_exhausted") "Cookbot is busy, wait a moment and try again" Billing-cycle credits used up
permission_denied("ai_feature_not_available") "make sure you are signed in" Plan has no AI at all
unavailable("quota_check_failed") "check your internet connection" Server refused to risk a quota bypass

grep -rhon 'Status::[a-z_]*("[a-z_]*"' cookbot/crates/server/src/grpc/*.rs returns those three and nothing else, and the quota one comes from a single site:

let total = usage.weighted_input_tokens + usage.weighted_output_tokens;
if total >= snapshot.tokens_allowed_this_cycle {
    return Err(Status::resource_exhausted("quota_exhausted"));
}

Each is now matched and explained. None of the three is helped by retrying, so none of them suggests it.

before: Cookbot is busy right now. Please wait a moment and try again.
after:  You have used all the Cookbot AI credits included in your plan for this
        billing cycle. They reset at the start of the next cycle - see Account for
        the date, or upgrade your plan for a larger allowance.

The deeper mistake

toUserFacing() kept only the status code and sent the server's explanation to a console the packaged app does not persist. So a user report contained nothing to diagnose from — this one was only pinned down by reading the server source, which is not a debugging strategy that generalises.

Statuses reflecting a server decision now keep the detail appended. Pure transport noise (read ECONNRESET) is still replaced, since the remedy does not depend on which socket died.

Also

  • Split the two causes of RESOURCE_EXHAUSTED. gRPC reuses it for the message size limit, where "wait and try again" is wrong for a different reason: every retry resends the same oversized conversation.
  • Raised max_receive_message_length from the grpc-js default of 4 MB, so a large response fails as a real error rather than a fake usage limit. max_send_message_length is deliberately left alone — verified in grpc-js/build/src/constants.js that it defaults to -1 (unlimited), so setting it would have introduced a cap. My first attempt did exactly that.

36 tests passing, lint clean. New cookbot-error.spec.ts covers each server reason, detail preservation, and the size-vs-quota split.

Follow-up worth its own issue

The packaged app persists no backend log, so console.error diagnostics are unrecoverable from a user report. Worth fixing separately — it is why this took a source dive rather than a log read.

A user hit `Cookbot is busy right now. Please wait a moment and try again.`
and retried four times, each failing instantly. The message was mine, and
it was wrong: the server had said the account's AI credits for the billing
cycle were used up, which no amount of waiting fixes.

The cause was collapsing a whole gRPC status into one guess. `cookbot`
returns exactly three machine-readable reasons, all previously flattened
into misleading guidance:

    Status::resource_exhausted("quota_exhausted")          -> "busy, wait a moment"
    Status::permission_denied("ai_feature_not_available")  -> "check you are signed in"
    Status::unavailable("quota_check_failed")              -> "check your internet connection"

Match those reasons and say what actually happened. None of the three is
helped by retrying, so none of them suggests it any more.

The deeper mistake was throwing the server's explanation away. Only the
status code survived, the detail went to a console the packaged app does
not persist, so the report was undiagnosable from the message alone -
this one was only pinned down by reading the server source. Statuses that
reflect a server decision now keep the server's detail appended; pure
transport noise (`read ECONNRESET`) is still replaced, since the remedy
does not depend on which socket died.

Also split the two unrelated causes of RESOURCE_EXHAUSTED: gRPC reuses it
for the message size limit, where "wait and try again" is likewise wrong
because every retry resends the same oversized conversation. And raise
`max_receive_message_length` from the grpc-js default of 4 MB, so a large
response fails as a real error rather than as a fake usage limit.
`max_send_message_length` is deliberately left alone - it defaults to -1
(unlimited), so setting it would impose a new cap.
Classifying the status correctly in isolation is not enough: the reason
also has to survive the retry loop and come out of request() as the text
the chat UI renders.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant