feat(coach): distinct daily-limit vs provider-rate-limit error UX

`coach.budget.exceeded` (per-user daily token cap, returned as JSON
429) now surfaces the dedicated "Daily limit reached; resets at 00:00
UTC." copy. `coach.provider.rate_limited` (every provider in the
fallback chain landed on 429, streamed as an SSE error frame) surfaces
"Provider temporarily rate-limited; retry in ~5 min." Other provider
failures keep the generic copy. The chat route emits the new
rate-limited code; the send hook decodes the JSON 429 envelope's
structured `error` field so the drawer can route the bubble copy.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:35:57 +02:00
1 parent d51e488739
commit b50ebccf0b
4 files changed
+155 -7

No files matched your search

+13 -1
View File
@@ -275,7 +275,19 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
firstStatus: err.attempts[0]?.httpStatus ?? null,
},
});
return streamProviderError({ code: "coach.provider.unavailable" });
// v1.4.25 W5 — distinguish provider rate-limit (every attempt
// landed on 429) from generic unavailability. The drawer's
// error-decoder surfaces the rate-limit copy with a warning
// toast instead of the generic provider-down message, so the
// user understands the limit is transient.
const allRateLimited =
err.attempts.length > 0 &&
err.attempts.every((a) => a.httpStatus === 429);
return streamProviderError({
code: allRateLimited
? "coach.provider.rate_limited"
: "coach.provider.unavailable",
});
}
throw err;
}
@@ -434,6 +434,42 @@ describe("<MessageThread>", () => {
expect(userIdx).toBeLessThan(assistantIdx);
});
// v1.4.25 W5 — distinct daily-limit vs provider-rate-limit copy.
it("surfaces the daily-limit copy for coach.budget.exceeded", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: "coach.budget.exceeded",
}}
/>,
);
expect(html).toContain("Daily limit reached; resets at 00:00 UTC.");
expect(html).not.toContain("could not reach an AI provider");
});
it("surfaces the provider rate-limit copy for coach.provider.rate_limited", () => {
const html = render(
<MessageThread
conversation={null}
streaming={{
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: "coach.provider.rate_limited",
}}
/>,
);
expect(html).toContain(
"Provider temporarily rate-limited; retry in ~5 min.",
);
});
it("suppresses the optimistic user bubble once its persisted twin lands", () => {
// baseConversation already carries a user message "Why was BP
// higher on Monday?". Passing the same content as the optimistic
@@ -462,3 +498,43 @@ describe("<MessageThread>", () => {
expect(userBubbles).toBe(1);
});
});
// v1.4.25 W5 — pin the error-code → i18n-key resolver so future code
// changes can't silently demote the daily-limit / rate-limit copy
// back to the generic provider-unavailable fallback.
import { errorCodeToI18nKey } from "../message-thread";
describe("errorCodeToI18nKey", () => {
it("maps daily-budget exceedance to the dedicated key", () => {
expect(errorCodeToI18nKey("coach.budget.exceeded")).toBe(
"insights.coach.dailyLimitBody",
);
});
it("maps provider rate-limit to the dedicated key", () => {
expect(errorCodeToI18nKey("coach.provider.rate_limited")).toBe(
"insights.coach.providerRateLimitBody",
);
});
it("maps every other provider failure to the generic copy", () => {
for (const code of [
"coach.provider.unavailable",
"coach.provider.empty",
"coach.provider.none",
"coach.network",
"coach.stream",
]) {
expect(errorCodeToI18nKey(code)).toBe("insights.coach.errorProvider");
}
});
it("forward-compats unknown codes with the namespaced key", () => {
expect(errorCodeToI18nKey("errorProvider")).toBe(
"insights.coach.errorProvider",
);
expect(errorCodeToI18nKey("brand-new-code")).toBe(
"insights.coach.brand-new-code",
);
});
});
@@ -63,6 +63,38 @@ function isPinnedToBottom(el: HTMLElement, slack = 64): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= slack;
}
/**
* v1.4.25 W5 — map server-emitted error codes to specific Coach i18n
* keys. The chat route distinguishes the daily user-quota
* (`coach.budget.exceeded`, returned as a JSON 429) from the provider
* rate-limit (`coach.provider.rate_limited`, streamed as an SSE error
* frame). Both used to surface as the generic provider-unavailable
* copy; we now route each to its dedicated translation so the user
* understands whether the limit is on their side (reset at UTC
* midnight) or transient on the provider side (retry in ~5 min).
*
* Exported so the resolver can be pinned by unit tests without
* standing up the whole thread renderer.
*/
export function errorCodeToI18nKey(code: string): string {
switch (code) {
case "coach.budget.exceeded":
return "insights.coach.dailyLimitBody";
case "coach.provider.rate_limited":
return "insights.coach.providerRateLimitBody";
case "coach.provider.unavailable":
case "coach.provider.empty":
case "coach.provider.none":
case "coach.network":
case "coach.stream":
return "insights.coach.errorProvider";
default:
// Forward-compat: try `insights.coach.<code>` for codes that
// ship their own translation (e.g. legacy `errorProvider`).
return `insights.coach.${code}`;
}
}
export function MessageThread({
conversation,
streaming,
@@ -336,12 +368,20 @@ function ChatBubble({
);
}
const errorMessage = errorCode ? t(`insights.coach.${errorCode}`, {}) : null;
// When a translated message comes back unchanged (i.e. key missing) we
// fall back to a generic provider error string so the bubble doesn't
// surface raw `coach.http.503` text to the user.
// v1.4.25 W5 — map server-emitted error codes to specific Coach
// i18n keys. Distinct user-quota and provider-rate-limit copy so the
// user understands daily-cap (resets at UTC midnight) vs. transient
// provider load (retry in ~5 min). Codes that have no dedicated
// translation fall back to the generic provider-unavailable copy.
const errorKey = errorCode
? errorCodeToI18nKey(errorCode)
: null;
const errorMessage = errorKey ? t(errorKey, {}) : null;
// When a translated message comes back unchanged (i.e. key missing)
// we fall back to a generic provider error string so the bubble
// doesn't surface raw `coach.http.503` text to the user.
const safeError =
errorMessage && errorMessage !== `insights.coach.${errorCode}`
errorMessage && errorMessage !== errorKey
? errorMessage
: errorCode
? t("insights.coach.errorProvider")
@@ -377,13 +377,33 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
.toLowerCase()
.startsWith("text/event-stream");
if (!response.ok && !isEventStream) {
// v1.4.25 W5 — for JSON errors the apiHandler envelope carries
// a structured `error` code (e.g. `coach.budget.exceeded`).
// Surface the structured code directly so the drawer can show
// the right copy + toast variant; fall back to the generic
// `coach.http.<status>` only when parsing the envelope fails.
let structured: string | null = null;
try {
const envelope = (await response.clone().json()) as {
error?: unknown;
};
if (typeof envelope?.error === "string") {
structured = envelope.error;
}
} catch {
// body was not JSON; fall through to the http-status fallback
}
setStreaming({
content: "",
metricSource: null,
inProgress: false,
messageId: null,
errorCode: `coach.http.${response.status}`,
errorCode: structured ?? `coach.http.${response.status}`,
});
// Drop the optimistic user bubble on the error path too so
// the next refetch — which still has the persisted user
// message — doesn't render the same bubble twice.
setOptimisticUser(null);
return;
}