From b50ebccf0b12e81844c74d970055d76360231880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:35:57 +0200 Subject: [PATCH] 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. --- src/app/api/insights/chat/route.ts | 14 +++- .../__tests__/message-thread.test.tsx | 76 +++++++++++++++++++ .../insights/coach-panel/message-thread.tsx | 50 ++++++++++-- .../insights/coach-panel/use-coach.ts | 22 +++++- 4 files changed, 155 insertions(+), 7 deletions(-) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 7d1591264..efe3f1eea 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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; } diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 443571ed5..4577cff51 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -434,6 +434,42 @@ describe("", () => { 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( + , + ); + 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( + , + ); + 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("", () => { 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", + ); + }); +}); diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 897029fa3..3e2420dab 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -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.` 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") diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 3b522fc10..50db8d297 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -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.` 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; }