mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
d51e488739
commit
b50ebccf0b
4 files changed
+155
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user