Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 37 additions & 38 deletions app/api/geocode/route.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -153,14 +129,37 @@ 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),
},
}
)
}

// 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(', ')

Expand Down
86 changes: 48 additions & 38 deletions components/geocoding-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,13 @@ const stateAbbreviations: Record<string, string> = {
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<void> {
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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
69 changes: 61 additions & 8 deletions lib/cache/kv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,24 @@ export async function deleteCached(key: string): Promise<void> {
inMemoryCache.delete(key)
}

interface CounterState {
count: number
windowStart: number
}

function getInMemoryCounter(key: string, ttl: number): CounterState | null {
const cached = inMemoryCache.get(key) as CacheEntry<number> | 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)
*/
Expand All @@ -145,29 +163,64 @@ export async function incrementCounter(key: string, ttl: number): Promise<number
}
}

// In-memory fallback
const current = (inMemoryCache.get(key) as CacheEntry<number> | 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<number> {
export async function getCounter(key: string, ttl?: number): Promise<number> {
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<number>(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<number> | undefined
return cached?.data ?? 0
if (ttl === undefined) {
const cached = inMemoryCache.get(key) as CacheEntry<number> | undefined
return {
count: cached?.data ?? 0,
windowStart: cached?.cachedAt ?? null,
}
}

const existing = getInMemoryCounter(key, ttl)
return {
count: existing?.count ?? 0,
windowStart: existing?.windowStart ?? null,
}
}

/**
Expand Down
Loading