Skip to content

Commit ce3836e

Browse files
Fix rate-limit overrides, AI error reports, and the login crash (#190)
* Fix rate-limit overrides, AI error reports, and the login crash Rate-limit lookups ordered by updated_at, which production does not have, so every override was ignored. Order by created_at and log the lookup failure. The new migration is not applied. Network failures are AINetworkError and stay out of Sentry. Unknown failures are AIUnknownError with a privacy-safe cause type. Other HTTP 5xx responses are AIServerError. A 401 or 403 from getUser signs out once and stops store reloads. Missing rows use maybeSingle. Edge Stripe uses the npm worker build and the fetch client. Optional analytics chunks no longer crash /login when a preload resolves to undefined. Fixes JAVASCRIPT-REACT-6 Fixes JAVASCRIPT-REACT-9 Co-authored-by: Gáborjáni-Szabó Ádám <Programcode@users.noreply.github.com> * Renumber rate_limits migration after production 20261022170000 --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Gáborjáni-Szabó Ádám <Programcode@users.noreply.github.com>
1 parent 09faf68 commit ce3836e

50 files changed

Lines changed: 1027 additions & 105 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎e2e/helpers/auth.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ export async function stubSupabaseSession(page: Page, opts: StubAuthOptions = {}
5353
body: JSON.stringify({ ...session, expires_at: Math.floor(Date.now() / 1000) + HOUR_SECONDS }),
5454
}));
5555

56+
// A restored session is confirmed with GET /user. A 401 or 403 signs the tab
57+
// out, so the stub user has to answer that call or the page returns to /login.
58+
await page.route((url) => url.pathname.endsWith('/auth/v1/user'), (route) => {
59+
if (route.request().method() !== 'GET') return route.fallback();
60+
return route.fulfill({
61+
status: 200,
62+
contentType: 'application/json',
63+
body: JSON.stringify(session.user),
64+
});
65+
});
66+
5667
// Credential-free render checks must not call the production announcement
5768
// function. Tests that exercise announcements register a more specific
5869
// route after this default.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"test:collaboration": "vitest run --config services/collaboration/vitest.config.ts",
2727
"start:collaboration": "tsx services/collaboration/src/index.ts",
2828
"test:watch": "vitest",
29-
"test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
29+
"test:edge": "deno test --config supabase/functions/deno.json --node-modules-dir=auto --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
3030
"test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-opus-default-migration.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh",
3131
"test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium",
3232
"test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke",

‎src/components/WebTelemetry.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { lazy, Suspense, useEffect, useState } from "react";
2+
import { loadOptionalComponent } from "@/components/optional-telemetry";
23

3-
const Analytics = lazy(() => import("@vercel/analytics/react").then(({ Analytics: Component }) => ({ default: Component })));
4-
const SpeedInsights = lazy(() => import("@vercel/speed-insights/react").then(({ SpeedInsights: Component }) => ({ default: Component })));
4+
const Analytics = lazy(() => loadOptionalComponent(() => import("@vercel/analytics/react"), "Analytics"));
5+
const SpeedInsights = lazy(() => loadOptionalComponent(() => import("@vercel/speed-insights/react"), "SpeedInsights"));
56

67
export function WebTelemetry() {
78
const [ready, setReady] = useState(false);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { ComponentType } from "react";
2+
3+
function HiddenTelemetry(): null {
4+
return null;
5+
}
6+
7+
/**
8+
* Analytics chunks are optional. Vite resolves a prevented preload error to
9+
* undefined, and destructuring that argument throws
10+
* "(destructured parameter) is undefined" in Firefox, which takes down /login.
11+
*/
12+
export function componentFromModule(
13+
module: Record<string, unknown> | null | undefined,
14+
exportName: string,
15+
): ComponentType {
16+
const component = module?.[exportName];
17+
return typeof component === "function" ? component as ComponentType : HiddenTelemetry;
18+
}
19+
20+
export function loadOptionalComponent(
21+
load: () => Promise<Record<string, unknown> | null | undefined>,
22+
exportName: string,
23+
): Promise<{ default: ComponentType }> {
24+
return Promise.resolve()
25+
.then(load)
26+
.then((module) => ({ default: componentFromModule(module, exportName) }))
27+
.catch(() => ({ default: HiddenTelemetry }));
28+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2+
import { renderHook, waitFor } from '@testing-library/react';
3+
import type { ReactNode } from 'react';
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
const orders: { table: string; column: string }[] = [];
7+
let rateLimitResult: { data: { limit_value: number } | null; error: { message: string } | null } = {
8+
data: { limit_value: 12 },
9+
error: null,
10+
};
11+
12+
function builder(table: string) {
13+
let column = '';
14+
const api = {
15+
select: () => api,
16+
eq: () => api,
17+
gte: () => api,
18+
neq: () => api,
19+
not: () => api,
20+
order: (next: string) => {
21+
column = next;
22+
orders.push({ table, column: next });
23+
return api;
24+
},
25+
limit: () => api,
26+
maybeSingle: () => Promise.resolve(table === 'user_settings'
27+
? { data: { subscription_tier: 'trial', daily_limit_override: null, trial_ends_at: '2099-01-01T00:00:00.000Z', grace_period_ends_at: null }, error: null }
28+
: table === 'rate_limits' ? rateLimitResult : { data: null, error: null }),
29+
then: (resolve: (value: unknown) => void, reject?: (reason: unknown) => void) => Promise.resolve(
30+
table === 'subscriptions'
31+
? { data: [], error: null }
32+
: table === 'ai_requests'
33+
? { count: 1, error: null }
34+
: { data: null, error: null },
35+
).then(resolve, reject),
36+
};
37+
return api;
38+
}
39+
40+
vi.mock('@/lib/supabase/AuthProvider', () => ({
41+
useAuth: () => ({ user: { id: 'user-1' } }),
42+
}));
43+
44+
vi.mock('@/lib/supabase/client', () => ({
45+
supabase: { from: (table: string) => builder(table) },
46+
}));
47+
48+
import { useSubscription } from './use-subscription';
49+
50+
function wrapper({ children }: { children: ReactNode }) {
51+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
52+
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
53+
}
54+
55+
describe('useSubscription rate limits', () => {
56+
beforeEach(() => {
57+
orders.length = 0;
58+
rateLimitResult = { data: { limit_value: 12 }, error: null };
59+
});
60+
61+
it('reads the account limit ordered by created_at and uses it', async () => {
62+
const { result } = renderHook(() => useSubscription(), { wrapper });
63+
await waitFor(() => expect(result.current.loading).toBe(false));
64+
65+
const rateOrders = orders.filter((order) => order.table === 'rate_limits');
66+
expect(rateOrders.length).toBeGreaterThan(0);
67+
expect(rateOrders.every((order) => order.column === 'created_at')).toBe(true);
68+
expect(orders.some((order) => order.column === 'updated_at')).toBe(false);
69+
expect(result.current.dailyLimit).toBe(12);
70+
expect(result.current.remainingToday).toBe(11);
71+
});
72+
73+
it('logs a lookup error and leaves the remaining count unknown', async () => {
74+
rateLimitResult = { data: null, error: { message: 'column rate_limits.updated_at does not exist' } };
75+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
76+
const { result } = renderHook(() => useSubscription(), { wrapper });
77+
await waitFor(() => expect(result.current.loading).toBe(false));
78+
79+
expect(warn).toHaveBeenCalledWith('[useSubscription] rate_limits lookup failed:', 'column rate_limits.updated_at does not exist');
80+
expect(result.current.remainingToday).toBeNull();
81+
warn.mockRestore();
82+
});
83+
});

‎src/hooks/use-subscription.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,13 @@ async function fetchSubscription(userId: string): Promise<SubscriptionSnapshot>
129129
.select('limit_value')
130130
.eq('user_id', userId)
131131
.eq('limit_type', 'daily_requests')
132-
.order('updated_at', { ascending: false })
132+
.order('created_at', { ascending: false })
133133
.limit(1)
134134
.maybeSingle();
135-
if (limitError) limitKnown = false;
135+
if (limitError) {
136+
console.warn('[useSubscription] rate_limits lookup failed:', limitError.message);
137+
limitKnown = false;
138+
}
136139
else if (typeof ownLimit?.limit_value === 'number') snapshot.dailyLimit = ownLimit.limit_value;
137140
}
138141

‎src/lib/ai/client.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,22 @@ export {
88
AIProviderError,
99
AIResponseError,
1010
TimeoutError,
11+
AINetworkError,
12+
AIServerError,
13+
AIUnknownError,
1114
isAIAuthError,
15+
isAINetworkError,
1216
isAIRateLimitError,
1317
isAIProviderError,
1418
isTimeoutError,
1519
isAISubscriptionError,
1620
isAIModelRestrictionError,
1721
classifyAIError,
1822
mapAIError,
23+
isBrowserNetworkFailure,
24+
browserIsOffline,
25+
privacySafeCauseType,
26+
offlineAIMessage,
1927
} from './errors';
2028
export type { AIErrorClassification } from './errors';
2129
export {

‎src/lib/ai/error-display.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import i18n from '@/i18n';
2-
import { AIProviderError, isAISubscriptionError } from './client';
2+
import { AIProviderError, isAINetworkError, isAISubscriptionError } from './errors';
33
import { subscriptionLimitMessage } from './limit-message';
44

55
const PROVIDER_LABELS: Record<string, string> = {
@@ -21,6 +21,7 @@ function providerLabel(provider?: string, message = ''): string {
2121
export { subscriptionLimitMessage };
2222

2323
export function getUserFacingAIError(error: unknown): string {
24+
if (isAINetworkError(error)) return i18n.t('aiErrors.offline');
2425
if (isAISubscriptionError(error)) return subscriptionLimitMessage(error);
2526

2627
if (error instanceof AIProviderError || (error instanceof Error && error.name === 'AIProviderError')) {

‎src/lib/ai/errors.test.ts‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
AIAuthError,
4+
AIModelRestrictionError,
5+
AINetworkError,
6+
AISubscriptionError,
7+
AIProviderError,
8+
AIServerError,
9+
AIUnknownError,
10+
TimeoutError,
11+
classifyAIError,
12+
mapAIError,
13+
} from './errors';
14+
15+
describe('classifyAIError', () => {
16+
it('treats a fetch TypeError as a network failure and does not capture it', () => {
17+
const classification = classifyAIError(new TypeError('Failed to fetch'));
18+
expect(classification).toMatchObject({ errorName: 'AINetworkError', shouldCaptureException: false });
19+
});
20+
21+
it('captures an unknown error under its own name with a privacy-safe cause type', () => {
22+
const classification = classifyAIError(new Error('student motion and private notes'));
23+
expect(classification).toMatchObject({
24+
errorName: 'AIUnknownError',
25+
shouldCaptureException: true,
26+
causeType: 'Error:Error',
27+
});
28+
expect(classification.causeType).not.toContain('student');
29+
expect(JSON.stringify(classification)).not.toContain('private notes');
30+
});
31+
32+
it('keeps subscription, model and timeout failures on their existing names', () => {
33+
expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).shouldCaptureException).toBe(false);
34+
expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).errorName).toBe('AISubscriptionError');
35+
expect(classifyAIError(new AIModelRestrictionError('upgrade')).errorName).toBe('AIModelRestrictionError');
36+
expect(classifyAIError(new AIAuthError('no', { statusCode: 403 })).shouldCaptureException).toBe(false);
37+
expect(classifyAIError(new TimeoutError())).toMatchObject({ errorName: 'TimeoutError', shouldCaptureException: true });
38+
});
39+
40+
it('recognizes the other browser network messages and an offline navigator', () => {
41+
expect(classifyAIError(new TypeError('NetworkError when attempting to fetch resource')).errorName).toBe('AINetworkError');
42+
expect(classifyAIError(new TypeError('Load failed')).errorName).toBe('AINetworkError');
43+
expect(classifyAIError(new TypeError('Network error')).errorName).toBe('AINetworkError');
44+
const online = navigator.onLine;
45+
Object.defineProperty(navigator, 'onLine', { configurable: true, value: false });
46+
try {
47+
expect(classifyAIError(new Error('socket closed')).errorName).toBe('AINetworkError');
48+
expect(classifyAIError(new AISubscriptionError('limit', { statusCode: 402 })).errorName).toBe('AISubscriptionError');
49+
} finally {
50+
Object.defineProperty(navigator, 'onLine', { configurable: true, value: online });
51+
}
52+
});
53+
});
54+
55+
describe('mapAIError', () => {
56+
it('maps a wrapped fetch failure to a network error without a status code', () => {
57+
const error = mapAIError(
58+
{ message: 'Failed to send a request to the Edge Function', context: new TypeError('Failed to fetch') },
59+
'req-1',
60+
'openrouter',
61+
'm',
62+
);
63+
expect(error).toBeInstanceOf(AINetworkError);
64+
expect(error.statusCode).toBeUndefined();
65+
expect(classifyAIError(error).shouldCaptureException).toBe(false);
66+
});
67+
68+
it('maps an unknown transport error without putting its message in the cause type', () => {
69+
const error = mapAIError({ message: 'student motion leaked' }, 'req-1', 'openrouter', 'm');
70+
expect(error).toBeInstanceOf(AIUnknownError);
71+
expect((error as AIUnknownError).causeType).toBe('Unknown:Unknown');
72+
expect((error as AIUnknownError).causeType).not.toContain('student');
73+
expect(classifyAIError(error).shouldCaptureException).toBe(true);
74+
});
75+
76+
it('still maps 402, 403 and abort messages as before', () => {
77+
const limited = mapAIError(
78+
{ message: 'Edge Function returned a non-2xx status code', context: { status: 402 } },
79+
'req-1',
80+
'openrouter',
81+
'm',
82+
{ error: 'limit', code: 'limit_reached' },
83+
);
84+
expect(limited).toBeInstanceOf(AISubscriptionError);
85+
expect(limited.statusCode).toBe(402);
86+
87+
const upgrade = mapAIError(
88+
{ message: 'Edge Function returned a non-2xx status code', context: { status: 403 } },
89+
'req-1',
90+
'openrouter',
91+
'm',
92+
{ code: 'upgrade_required', error: 'This model requires Pro' },
93+
);
94+
expect(upgrade).toBeInstanceOf(AIModelRestrictionError);
95+
96+
const forbidden = mapAIError(
97+
{ message: 'Edge Function returned a non-2xx status code', context: { status: 403 } },
98+
'req-1',
99+
'openrouter',
100+
'm',
101+
{ error: 'Access denied' },
102+
);
103+
expect(forbidden).toBeInstanceOf(AIAuthError);
104+
105+
expect(mapAIError({ message: 'The operation was aborted' }, 'req-1', 'openrouter', 'm')).toBeInstanceOf(TimeoutError);
106+
});
107+
108+
it('keeps a provider_error 500 on AIProviderError and other 500s on AIServerError', () => {
109+
const provider = mapAIError(
110+
{ message: 'Edge Function returned a non-2xx status code', context: { status: 500 } },
111+
'req-1',
112+
'openrouter',
113+
'm',
114+
{ error: 'AI request failed. Please try again.', code: 'provider_error' },
115+
);
116+
expect(provider).toBeInstanceOf(AIProviderError);
117+
expect(provider.statusCode).toBe(500);
118+
expect(classifyAIError(provider).errorName).toBe('AIProviderError');
119+
120+
const server = mapAIError(
121+
{ message: 'Edge Function returned a non-2xx status code', context: { status: 500 } },
122+
'req-1',
123+
'openrouter',
124+
'm',
125+
{ error: 'Unable to verify your quota. Please try again.' },
126+
);
127+
expect(server).toBeInstanceOf(AIServerError);
128+
expect(server.statusCode).toBe(500);
129+
expect(classifyAIError(server)).toMatchObject({ errorName: 'AIServerError', statusCode: 500, shouldCaptureException: true });
130+
});
131+
});

0 commit comments

Comments
 (0)