diff --git a/app/api/geocode/route.ts b/app/api/geocode/route.ts index 78c6e13..868a891 100644 --- a/app/api/geocode/route.ts +++ b/app/api/geocode/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' -import { getCached, setCached, incrementCounter, getCounter } from '@/lib/cache/kv' +import { getCached, setCached, incrementCounter, getCounterState } from '@/lib/cache/kv' import { dedupeRequest } from '@/lib/cache/dedupe' import { recordAPIMetric, recordRateLimit } from '@/lib/monitoring/metrics' @@ -24,7 +24,8 @@ interface NominatimResult { } const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute -const RATE_LIMIT_MAX_REQUESTS = 10 // Max 10 requests per minute per IP +// Match Nominatim's 1 req/sec guidance while allowing reasonable batch geocoding +const RATE_LIMIT_MAX_REQUESTS = 60 const CACHE_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days function getRateLimitKey(request: NextRequest): string { @@ -35,23 +36,19 @@ function getRateLimitKey(request: NextRequest): string { async function checkRateLimit(key: string): Promise<{ allowed: boolean; remaining: number; resetAt: number }> { const now = Date.now() - const count = await getCounter(key) - - if (count === 0) { - // First request in window - await incrementCounter(key, RATE_LIMIT_WINDOW) - return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1, resetAt: now + RATE_LIMIT_WINDOW } - } + const { count, windowStart } = await getCounterState(key, RATE_LIMIT_WINDOW) + const resetAt = (windowStart ?? now) + RATE_LIMIT_WINDOW if (count >= RATE_LIMIT_MAX_REQUESTS) { - // Rate limit exceeded - calculate reset time - const resetAt = now + RATE_LIMIT_WINDOW return { allowed: false, remaining: 0, resetAt } } - // Increment counter - await incrementCounter(key, RATE_LIMIT_WINDOW) - return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - count - 1, resetAt: now + RATE_LIMIT_WINDOW } + const newCount = await incrementCounter(key, RATE_LIMIT_WINDOW) + return { + allowed: true, + remaining: RATE_LIMIT_MAX_REQUESTS - newCount, + resetAt: (windowStart ?? now) + RATE_LIMIT_WINDOW, + } } function getCacheKey(address: string, city?: string, state?: string): string { @@ -104,29 +101,7 @@ export async function POST(request: NextRequest) { const startTime = Date.now() try { - // Rate limiting const rateLimitKey = getRateLimitKey(request) - const rateLimit = await checkRateLimit(rateLimitKey) - - if (!rateLimit.allowed) { - recordRateLimit('/api/geocode') - return NextResponse.json( - { - error: 'Rate limit exceeded', - message: `Too many requests. Please try again after ${new Date(rateLimit.resetAt).toISOString()}`, - }, - { - status: 429, - headers: { - 'X-RateLimit-Limit': String(RATE_LIMIT_MAX_REQUESTS), - 'X-RateLimit-Remaining': String(rateLimit.remaining), - 'X-RateLimit-Reset': new Date(rateLimit.resetAt).toISOString(), - 'Retry-After': String(Math.ceil((rateLimit.resetAt - Date.now()) / 1000)), - 'X-Response-Time': String(Date.now() - startTime), - }, - } - ) - } // Parse request body const body = (await request.json()) as GeocodeRequest @@ -136,11 +111,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid request: address is required' }, { status: 400 }) } - // Check cache first + // Check cache first — cached responses should not consume rate limit budget const cacheKey = getCacheKey(address, city, state) const cached = await getCached<{ lat: number; lng: number }>(cacheKey, CACHE_TTL) if (cached) { + const { count } = await getCounterState(rateLimitKey, RATE_LIMIT_WINDOW) const duration = Date.now() - startTime recordAPIMetric('/api/geocode', duration, true) return NextResponse.json( @@ -153,7 +129,7 @@ export async function POST(request: NextRequest) { { headers: { 'X-RateLimit-Limit': String(RATE_LIMIT_MAX_REQUESTS), - 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Remaining': String(Math.max(RATE_LIMIT_MAX_REQUESTS - count, 0)), 'X-Cache': 'HIT', 'X-Response-Time': String(duration), }, @@ -161,6 +137,29 @@ export async function POST(request: NextRequest) { ) } + // Rate limit only applies to upstream Nominatim lookups + const rateLimit = await checkRateLimit(rateLimitKey) + + if (!rateLimit.allowed) { + recordRateLimit('/api/geocode') + return NextResponse.json( + { + error: 'Rate limit exceeded', + message: `Too many requests. Please try again after ${new Date(rateLimit.resetAt).toISOString()}`, + }, + { + status: 429, + headers: { + 'X-RateLimit-Limit': String(RATE_LIMIT_MAX_REQUESTS), + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': new Date(rateLimit.resetAt).toISOString(), + 'Retry-After': String(Math.max(Math.ceil((rateLimit.resetAt - Date.now()) / 1000), 1)), + 'X-Response-Time': String(Date.now() - startTime), + }, + } + ) + } + // Build full address string const fullAddress = [address, city, state].filter(Boolean).join(', ') diff --git a/components/geocoding-section.tsx b/components/geocoding-section.tsx index 91c74d7..d93a8f1 100644 --- a/components/geocoding-section.tsx +++ b/components/geocoding-section.tsx @@ -117,9 +117,13 @@ const stateAbbreviations: Record = { wyoming: 'WY', }; -// Helper for randomized delay -function randomDelay(min: number, max: number) { - return Math.floor(Math.random() * (max - min + 1)) + min; +const NOMINATIM_DELAY_MS = 1100; // Nominatim usage policy: max 1 request per second + +async function waitForRateLimitReset(response: Response): Promise { + const retryAfterHeader = response.headers.get('Retry-After'); + const retryAfterSeconds = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : 60; + const waitMs = (Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : 60) * 1000 + 500; + await new Promise((resolve) => setTimeout(resolve, waitMs)); } export function GeocodingSection({ @@ -369,43 +373,55 @@ export function GeocodingSection({ } // 3. Use API proxy (which handles server-side caching and rate limiting) - try { - const response = await fetch('/api/geocode', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ address, city, state }), - }); + const maxAttempts = 4; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const response = await fetch('/api/geocode', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ address, city, state }), + }); - if (!response.ok) { if (response.status === 429) { + if (attempt < maxAttempts - 1) { + await waitForRateLimitReset(response); + continue; + } const error = await response.json(); throw new Error(error.message || 'Rate limit exceeded. Please try again later.'); } - const error = await response.json(); - throw new Error(error.message || `Geocoding failed: ${response.statusText}`); - } - const result = await response.json(); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || `Geocoding failed: ${response.statusText}`); + } - // Cache the result in both session and persistent storage (using addressKey) - sessionCache[addressKey] = { lat: result.lat, lng: result.lng }; - if (result.source === 'api') { - // Only save to localStorage if it came from API (not already cached on server) - saveCachedLocation(addressKey, result.lat, result.lng, 'nominatim'); - } + const result = await response.json(); - return { - lat: result.lat, - lng: result.lng, - fromCache: result.cached || false, - source: result.cached ? 'persistent' : 'api', - }; - } catch (error) { - console.warn(`Geocoding failed for address: ${address}`, error); - throw error; + // Cache the result in both session and persistent storage (using addressKey) + sessionCache[addressKey] = { lat: result.lat, lng: result.lng }; + if (result.source === 'api') { + // Only save to localStorage if it came from API (not already cached on server) + saveCachedLocation(addressKey, result.lat, result.lng, 'nominatim'); + } + + return { + lat: result.lat, + lng: result.lng, + fromCache: result.cached || false, + source: result.cached ? 'persistent' : 'api', + }; + } catch (error) { + if (attempt === maxAttempts - 1) { + console.warn(`Geocoding failed for address: ${address}`, error); + throw error; + } + } } + + throw new Error(`Geocoding failed for address: ${address}`); }; // Execute the actual geocoding process @@ -533,13 +549,7 @@ export function GeocodingSection({ if (!result.fromCache) { apiCallCount++; - let delay = 0; - if (apiCallCount <= 10) { - delay = randomDelay(200, 400); - } else { - delay = randomDelay(800, 1500); - } - await new Promise((resolve) => setTimeout(resolve, delay)); + await new Promise((resolve) => setTimeout(resolve, NOMINATIM_DELAY_MS)); } } catch (error) { console.warn(`Failed to geocode: ${address}`, error); diff --git a/lib/cache/kv.ts b/lib/cache/kv.ts index 42a64b7..c6a7797 100644 --- a/lib/cache/kv.ts +++ b/lib/cache/kv.ts @@ -125,6 +125,24 @@ export async function deleteCached(key: string): Promise { inMemoryCache.delete(key) } +interface CounterState { + count: number + windowStart: number +} + +function getInMemoryCounter(key: string, ttl: number): CounterState | null { + const cached = inMemoryCache.get(key) as CacheEntry | undefined + if (!cached) return null + + const age = Date.now() - cached.cachedAt + if (age > ttl) { + inMemoryCache.delete(key) + return null + } + + return { count: cached.data, windowStart: cached.cachedAt } +} + /** * Increment a counter in KV or in-memory fallback (for rate limiting) */ @@ -145,29 +163,64 @@ export async function incrementCounter(key: string, ttl: number): Promise | undefined)?.data ?? 0 - const newValue = current + 1 - await setCached(key, newValue, ttl) + // In-memory fallback — preserve window start so TTL is not extended on every increment + const existing = getInMemoryCounter(key, ttl) + const newValue = (existing?.count ?? 0) + 1 + inMemoryCache.set(key, { + data: newValue, + cachedAt: existing?.windowStart ?? Date.now(), + }) return newValue } /** * Get counter value */ -export async function getCounter(key: string): Promise { +export async function getCounter(key: string, ttl?: number): Promise { + const state = await getCounterState(key, ttl) + return state.count +} + +/** + * Get counter value and the start of its active window (for rate-limit reset times) + */ +export async function getCounterState( + key: string, + ttl?: number +): Promise<{ count: number; windowStart: number | null }> { const kv = await getKVClient() if (kv) { try { const value = await kv.get(key) - return value ?? 0 + if (value === null || value === undefined) { + return { count: 0, windowStart: null } + } + + const ttlMs = ttl ?? 60_000 + const ttlSeconds = Math.ceil(ttlMs / 1000) + const remainingSeconds = await kv.ttl(key) + const windowStart = + remainingSeconds > 0 ? Date.now() - (ttlSeconds - remainingSeconds) * 1000 : Date.now() + + return { count: value, windowStart } } catch (error) { console.warn('[Cache] KV error, falling back to in-memory:', error) } } - const cached = inMemoryCache.get(key) as CacheEntry | undefined - return cached?.data ?? 0 + if (ttl === undefined) { + const cached = inMemoryCache.get(key) as CacheEntry | undefined + return { + count: cached?.data ?? 0, + windowStart: cached?.cachedAt ?? null, + } + } + + const existing = getInMemoryCounter(key, ttl) + return { + count: existing?.count ?? 0, + windowStart: existing?.windowStart ?? null, + } } /**