Files
Portfolio/src/utils/rateLimitSupabase.ts
T
damjan_savicandClaude Sonnet 4.5 281627beda fix: Rate limiter fail-open bug - wrap createClient in try/catch (qa-requested)
Fixes:
- Rate limiter now properly fails open when Supabase unavailable
- Moved createClient() calls inside try/catch blocks
- Prevents unhandled exceptions from reaching API handler
- Improved error logging for debugging

Impact:
- isRateLimited() - Wrapped createClient (line 22)
- getRemainingAttempts() - Wrapped createClient (line 93)
- getTimeToReset() - Wrapped createClient (line 128)

Context:
- QA Session 2 found API returning 500 errors
- Root cause: Database table doesn't exist (requires manual migration)
- This fix ensures rate limiter fails gracefully when DB unavailable
- With DB present, rate limiting will work as designed

Verified:
- TypeScript compiles without errors
- Code follows fail-open pattern
- Error logging improved

QA Fix Session: 2

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 13:09:40 +01:00

161 lines
5.2 KiB
TypeScript

import { createClient } from '@/lib/supabase/server';
interface RateLimitRecord {
identifier: string;
count: number;
window_start: string;
created_at?: string;
updated_at?: string;
}
const WINDOW_SIZE_MS = 3600000; // 1 hour
const MAX_REQUESTS = 5; // Maximum requests per hour
class SupabaseRateLimiter {
/**
* Check if an identifier (IP address) is rate limited
* @param identifier - The IP address or unique identifier to check
* @returns true if rate limited, false otherwise
*/
async isRateLimited(identifier: string): Promise<boolean> {
try {
const supabase = await createClient();
const now = Date.now();
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
// Get the current rate limit record for this identifier
const { data: existingRecord, error: fetchError } = await supabase
.from('rate_limits')
.select('*')
.eq('identifier', identifier)
.gte('window_start', windowStart)
.order('window_start', { ascending: false })
.limit(1)
.single();
if (fetchError && fetchError.code !== 'PGRST116') {
// PGRST116 is "no rows returned" which is fine
console.error('Error fetching rate limit:', fetchError);
return false; // Fail open - don't block on errors
}
// No existing record or window expired - create new record
if (!existingRecord || new Date(existingRecord.window_start).getTime() < now - WINDOW_SIZE_MS) {
const { error: insertError } = await supabase
.from('rate_limits')
.insert({
identifier,
count: 1,
window_start: new Date(now).toISOString()
});
if (insertError) {
console.error('Error inserting rate limit:', insertError);
return false; // Fail open
}
return false; // First request in new window
}
// Check if limit exceeded
if (existingRecord.count >= MAX_REQUESTS) {
return true; // Rate limited
}
// Increment counter
const { error: updateError } = await supabase
.from('rate_limits')
.update({
count: existingRecord.count + 1,
updated_at: new Date().toISOString()
})
.eq('identifier', identifier)
.eq('window_start', existingRecord.window_start);
if (updateError) {
console.error('Error updating rate limit:', updateError);
return false; // Fail open
}
return false; // Not rate limited yet
} catch (error) {
console.error('[RateLimiter] Unexpected error in isRateLimited - FAILING OPEN:', error);
return false; // Fail open on ALL errors
}
}
/**
* Get the number of remaining attempts for an identifier
* @param identifier - The IP address or unique identifier to check
* @returns Number of remaining attempts (0 if rate limited)
*/
async getRemainingAttempts(identifier: string): Promise<number> {
try {
const supabase = await createClient();
const now = Date.now();
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
const { data: record, error } = await supabase
.from('rate_limits')
.select('count')
.eq('identifier', identifier)
.gte('window_start', windowStart)
.order('window_start', { ascending: false })
.limit(1)
.single();
if (error && error.code !== 'PGRST116') {
console.error('Error fetching remaining attempts:', error);
return MAX_REQUESTS; // Fail open
}
if (!record) {
return MAX_REQUESTS; // No record yet
}
return Math.max(0, MAX_REQUESTS - record.count);
} catch (error) {
console.error('[RateLimiter] Unexpected error in getRemainingAttempts - FAILING OPEN:', error);
return MAX_REQUESTS; // Fail open on ALL errors
}
}
/**
* Get the time in milliseconds until the rate limit resets
* @param identifier - The IP address or unique identifier to check
* @returns Milliseconds until reset (0 if no active limit)
*/
async getTimeToReset(identifier: string): Promise<number> {
try {
const supabase = await createClient();
const now = Date.now();
const windowStart = new Date(now - WINDOW_SIZE_MS).toISOString();
const { data: record, error } = await supabase
.from('rate_limits')
.select('window_start')
.eq('identifier', identifier)
.gte('window_start', windowStart)
.order('window_start', { ascending: false })
.limit(1)
.single();
if (error && error.code !== 'PGRST116') {
console.error('Error fetching time to reset:', error);
return 0;
}
if (!record) {
return 0; // No active window
}
const windowStartTime = new Date(record.window_start).getTime();
const resetTime = windowStartTime + WINDOW_SIZE_MS;
return Math.max(0, resetTime - now);
} catch (error) {
console.error('[RateLimiter] Unexpected error in getTimeToReset - FAILING OPEN:', error);
return 0; // Fail open on ALL errors
}
}
}
export const supabaseRateLimiter = new SupabaseRateLimiter();
export { WINDOW_SIZE_MS, MAX_REQUESTS };