← Complete Projects

阅读中文版本

Build a Geocoding Proxy with Cache and Rate Limiting

A complete geocoding proxy: provider abstraction, negative caching, per-key rate limits, quota metering, and cache-behavior tests.

intermediate~4 hgeocodingcachingquotanodeapi

Project goal

Proxy geocoding lookups to a paid provider, cache positive and negative results, and enforce per-caller quotas so one client cannot exhaust the shared plan.

Final features

  • Normalize query
  • Positive and negative cache
  • Global rate limit
  • Per-caller quota
  • Provider mapping

Architecture

The proxy normalizes the address query, checks Redis for a hit, and on a miss calls the provider under a global token bucket. Results, including empty ones, are cached with different TTLs; per-caller usage is metered separately.

Prerequisites

  • Node.js 20 LTS or later
  • Redis 7+
  • A geocoding provider API key

Dependencies

  • ioredis ^5.4.1 — Result and quota storage
  • undici ^6.19.0 — Provider HTTP calls
  • express ^4.21.0 — Proxy endpoint

Directory structure

geocoding-proxy/
├── src/normalize.ts
├── src/provider.ts
├── src/cache.ts
├── src/quota.ts
├── tests/geocode.test.ts
└── package.json

Phases

1. Normalize query

Lowercase, trim, and collapse whitespace into a stable cache key.

const key = 'geo:' + sha1(normalize(address));

Expected behavior: Non-deterministic keys destroy the cache hit rate.

2. Positive and negative cache

Cache hits with a long TTL and misses with a short one.

await redis.set(key, JSON.stringify(result ?? null), 'EX', result ? 86400 : 600);

Expected behavior: Repeated bad addresses otherwise hit the provider every time.

3. Global rate limit

Protect the provider plan with a shared token bucket.

if (!globalBucket.take()) return res.status(429).end();

Expected behavior: The plan quota is shared across all callers.

4. Per-caller quota

Meter requests per API key and reject over-quota callers.

const used = await redis.incr('quota:' + apiKey + ':' + day);

Expected behavior: One client must not consume the shared quota.

5. Provider mapping

Normalize the provider response into a stable shape.

return { lat: r.geometry.lat, lng: r.geometry.lng, label: r.formatted };

Expected behavior: Callers should not depend on provider-specific fields.

Testing

Call the same address twice and assert one provider call. Call an unknown address twice and assert negative caching. Exceed a caller quota and assert 429.

Troubleshooting

Hit rate is low

The key included unnormalized input; normalize before hashing.

Provider quota exhausted early

Negative results were not cached, so bad addresses re-hit the provider.

Deployment

Keep the provider key server-side and rotate it via a secret manager. Monitor provider spend and cap the global bucket below the monthly plan.

Known limitations

  • No reverse geocoding or batch endpoint.
  • Provider-specific confidence scores are dropped.
  • Cache keys ignore locale.

Project overview

A geocoding provider bills per lookup, and a naive proxy will happily pass every request straight through. This build wraps the provider in a cache and a quota so a single caller cannot spend the shared plan.

You will normalize the incoming address into a stable key, check a cache for both hits and misses, call the provider under a shared rate limit, and meter usage per caller.

Caching negative results is the less obvious half of the design and often the more valuable: a miss that repeats is exactly as expensive as a hit that repeats, and it is the one an unknown address produces every time.

The service is a boundary around a paid dependency, so it exists to make that dependency predictable in cost and latency while keeping the API key out of the client.

Each phase adds one safety: normalization, the cache, the global limit, per-caller quotas, and response mapping, so the proxy is useful after any of them.

The service is written for teams that have been surprised by a provider bill, and it makes the spend boundary explicit: every path either avoids a call or records one. It is small enough that the whole cost story fits on one diagram, which is exactly the property that makes it trustworthy.

It is deliberately thin: normalize, cache, limit, meter, and map, with no attempt to reconcile multiple providers or to validate that a match is plausible. That narrow scope is what lets the cost story stay auditable.

What you will build

The proxy provides five capabilities, each with a test that counts provider calls:

  • Normalize query
  • Positive and negative cache
  • Global rate limit
  • Per-caller quota
  • Provider mapping

Normalization makes equivalent queries share a key; the cache serves hits and remembers misses; a global rate limit protects the plan; per-caller quotas stop one client from exhausting it; and response mapping hides provider-specific shapes from callers.

The design treats the provider as unreliable and expensive, so every path either avoids a call or is prepared to absorb one, which is what keeps latency and spend bounded.

Architecture and data model

The proxy normalizes the address query, checks Redis for a hit, and on a miss calls the provider under a global token bucket. Results, including empty ones, are cached with different TTLs; per-caller usage is metered separately.

A lookup normalizes the address to a canonical key, checks Redis, and on a miss calls the provider under a token bucket. The result is cached with a lifetime chosen by whether it was a hit or a miss.

Negative results get a shorter lifetime than positive ones, because an address that does not exist today may exist tomorrow, while a resolved address rarely changes.

The global bucket bounds total spend regardless of caller, and per-caller counters bound each client separately. The two limits answer different questions and both are needed.

The provider response is mapped to a stable internal shape at the boundary, so a provider change or swap does not ripple through every caller.

The cache key is the normalized query plus the provider parameters that affect the result, so two requests that would produce different answers never share an entry. Getting the key wrong is how a cache serves the wrong place confidently, which is worse than a miss.

Prerequisites and dependencies

You need Redis for the cache and counters, plus provider credentials and a terms-of-service-compliant usage plan.

  • Node.js 20 LTS or later
  • Redis 7+
  • A geocoding provider API key

The dependencies cover the HTTP surface, the cache, and the provider call:

  • ioredis (^5.4.1) — Result and quota storage
  • undici (^6.19.0) — Provider HTTP calls
  • express (^4.21.0) — Proxy endpoint
pnpm init
pnpm add ioredis undici express
pnpm add -D typescript tsx vitest

Keep the provider key server-side and rotate it through a secret manager, so a leaked client cannot spend your plan. Record the plan ceiling in configuration and set the global bucket below it with headroom.

Directory structure

Separate normalization, the cache read and write, the rate limit, per-caller metering, and response mapping so each is testable with a fake provider:

geocoding-proxy/
├── src/normalize.ts
├── src/provider.ts
├── src/cache.ts
├── src/quota.ts
├── tests/geocode.test.ts
└── package.json

Phase 1: Normalize query

Lowercase, trim, and collapse whitespace into a stable cache key.

const key = 'geo:' + sha1(normalize(address));

Non-deterministic keys destroy the cache hit rate.

Normalize the address before keying the cache: trim, lowercase, collapse whitespace, and resolve obvious abbreviations. Two spellings of the same place should share a key, or the cache leaks calls.

Phase 2: Positive and negative cache

Cache hits with a long TTL and misses with a short one.

await redis.set(key, JSON.stringify(result ?? null), 'EX', result ? 86400 : 600);

Repeated bad addresses otherwise hit the provider every time.

Cache hits with a generous lifetime and misses with a short one, and always record that a miss was cached. Negative caching is the difference between one provider call and one per repeated typo.

Phase 3: Global rate limit

Protect the provider plan with a shared token bucket.

if (!globalBucket.take()) return res.status(429).end();

The plan quota is shared across all callers.

Apply a global token bucket before the provider call, so the aggregate rate stays under the plan even when many callers arrive at once. A per-caller limit alone cannot protect a shared plan.

Phase 4: Per-caller quota

Meter requests per API key and reject over-quota callers.

const used = await redis.incr('quota:' + apiKey + ':' + day);

One client must not consume the shared quota.

Meter usage per caller on every call, including cached ones if the plan charges for them, and reject once a quota is exhausted. Metering is what makes a shared plan fair rather than first-come.

Phase 5: Provider mapping

Normalize the provider response into a stable shape.

return { lat: r.geometry.lat, lng: r.geometry.lng, label: r.formatted };

Callers should not depend on provider-specific fields.

Map the provider response into a stable shape, and translate provider errors into your own error codes so callers do not depend on provider-specific details.

Testing

Call the same address twice and assert one provider call. Call an unknown address twice and assert negative caching. Exceed a caller quota and assert 429.

Call the same address twice and assert exactly one provider call, which is the cache working as intended. Use a fake provider to count calls rather than inferring from latency.

Call an unknown address twice and assert that the second call is served from the negative cache, since this is the case that quietly doubles spend in production.

Exceed a caller quota and assert 429 while a second caller remains unaffected, proving limits are per caller rather than global.

Assert that the global bucket rate-limits under concurrent load, because this is the only limit that protects the plan from a coordinated burst.

Assert the normalization rules directly with pairs of spellings that should collide and pairs that should not, because the cache is only as correct as its key and a loose key silently merges different places. Then assert the provider call count for repeated queries, which is the property the cache exists to provide.

Assert as well that a quota reset restores a caller’s allowance at the window boundary, so the limit is understood as a window rather than a lifetime cap.

pnpm vitest run

Deployment and operations

Keep the provider key server-side and rotate it via a secret manager. Monitor provider spend and cap the global bucket below the monthly plan.

Monitor provider spend against the plan and alert before the ceiling, because the bill is the failure mode this service exists to prevent.

Rotate the provider key through a secret manager and support two valid keys during a rotation window, since a hard cutover is an outage on a path you do not control.

Warm the cache for known-popular queries after a deploy, so the first burst does not hit the provider all at once.

Keep the cache and counters in a store that survives a restart, because an in-process cache that vanishes resets every quota and re-exposes the plan.

Track both the cache hit ratio and provider spend on the same dashboard, because the two explain each other: a spend spike with a stable ratio means traffic rose, while a spend spike with a falling ratio means the key changed or the cache was flushed. Set the global bucket with headroom below the plan so a bad day degrades rather than overspends.

Security considerations

Never expose the provider key to clients, validate and length-cap addresses, and scope quota keys by authenticated caller.

Never expose the provider key to callers, and never include it in an error message or a log line, since a leaked key spends your money and is immediately visible to the provider.

Treat the address as potentially personal data; keep retention short and avoid logging full queries, because a geocoding log is a record of where users are interested.

Validate and bound the input length before normalizing, so an oversized address cannot be used to exhaust memory or the cache key space.

Rate-limit by caller identity rather than by IP where a stable identity exists, since IP-based quotas unfairly penalize users behind a shared network.

Keep retention of query logs short and treat an address as personal data, because a geocoding log is a searchable record of places users cared about. Never return provider diagnostics that include the account or the key id, since those belong to your account rather than to the caller.

Observability

Track cache hit ratio, provider latency, quota rejections, and estimated spend. Alert when spend pace exceeds the monthly budget.

Track the cache hit ratio, because it is the single number that determines both latency and spend, and a falling ratio is the earliest sign of a keying bug.

Chart provider calls per minute against the plan, and alert on the approach to the limit rather than on crossing it.

Measure quota rejections per caller, since one caller hitting the limit repeatedly is a sign of a loop or a misunderstanding rather than a capacity problem.

Log provider error rates separately from cache errors, because they point at different owners and different fixes.

Alert on the approach to the plan ceiling rather than on crossing it, since the point of the service is to stop before the bill, not to report it afterward. Track quota rejections per caller separately, because a single caller hitting the limit repeatedly points at a loop rather than at capacity.

Troubleshooting

Hit rate is low. The key included unnormalized input; normalize before hashing.

Provider quota exhausted early. Negative results were not cached, so bad addresses re-hit the provider.

If the provider bill spikes, check the cache hit ratio and the key normalization first, because a tiny change to the key format can invalidate the whole cache.

If a caller gets 429 despite making few requests, check whether their identity is stable and whether another process shares it.

If the bill rises without a traffic increase, check key normalization and cache health first, because a flushed cache or a key change effectively removes the cache while every other metric looks normal. Then check whether a second process is sharing the same caller identity and consuming the quota unexpectedly.

Limitations and next steps

  • No reverse geocoding or batch endpoint.
  • Provider-specific confidence scores are dropped.
  • Cache keys ignore locale.

The cache is keyed by normalized text rather than by a real place identifier, so two genuinely different places with the same string share a key, which the provider parameters should disambiguate.

There is no automatic failover to a second provider, so a provider outage stops geocoding rather than degrading it.

Quotas are fixed windows rather than smooth buckets, so a caller can burst at a window boundary, which the global bucket bounds but does not eliminate.

The service does not validate that a result is plausible, so a provider returning a wrong match is cached and served as confidently as a correct one.

The proxy does not arbitrate between providers, and it does not coalesce identical concurrent misses, so a burst of the same cold query calls the provider once per request until the cache fills; a single-flight wrapper is the natural next improvement.

The API exposes no cache management beyond the read and write path, so warming or invalidating a specific key is an operator task performed through the underlying store rather than through the service.