Use Redis for rate limiting middleware and cache-aside lookups in a go-chi API, with Encore or go-redis and ElastiCache.

chi keeps everything as net/http, which makes Redis easy to add in the places Go developers expect it: a middleware that runs before every route and a helper the handlers call. The Go code is a few dozen lines, and the time goes elsewhere: a Redis for each developer, an ElastiCache or Memorystore cluster in each environment, TLS and auth settings, and a network path from the app to a cache that is only reachable inside the VPC.
This guide adds Redis to a chi currency API that rate limits each API key and caches exchange rates from an upstream provider. It starts with Encore, which declares the cache in Go and runs it locally and in your own AWS or GCP account, then shows the same middleware with go-redis and ElastiCache, and the chi-native httprate package.
Encore works alongside chi here: the router, the middleware chain and the handlers stay chi code, and the Redis they use is declared as a typed Go value next to them. Encore mounts the router in one raw endpoint with a fallback route, and the caching API gives you keyspaces with typed keys and values instead of hand-built key strings.
Run in your terminal to get started locally.
The service below declares one cache cluster with two keyspaces. rates caches the provider response for ten minutes, and requests counts calls per API key per minute, with the minute in the key so each window starts from zero and the previous counter expires on its own. The rate limiter is a normal chi middleware registered with r.Use:
// fx/fx.go
package fx
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"time"
"encore.dev/storage/cache"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// In-memory Redis with `encore run`, ElastiCache or Memorystore when deployed.
var cluster = cache.NewCluster("fx", cache.ClusterConfig{
EvictionPolicy: cache.AllKeysLRU,
})
// Rates for one base currency, cached for ten minutes.
type Rates struct {
Base string `json:"base"`
Date string `json:"date"`
Rates map[string]float64 `json:"rates"`
}
var rates = cache.NewStructKeyspace[string, Rates](cluster, cache.KeyspaceConfig{
KeyPattern: "rates/:key",
DefaultExpiry: cache.ExpireIn(10 * time.Minute),
})
// One counter per API key per minute. The minute is part of the key,
// so each window starts at zero and the old counter expires on its own.
type window struct {
APIKey string
Minute int64
}
var requests = cache.NewIntKeyspace[window](cluster, cache.KeyspaceConfig{
KeyPattern: "ratelimit/:APIKey/:Minute",
DefaultExpiry: cache.ExpireIn(2 * time.Minute),
})
const limitPerMinute = 60
var currency = regexp.MustCompile(`^[A-Z]{3}$`)
//encore:service
type Service struct {
router chi.Router
}
func initService() (*Service, error) {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(rateLimit)
r.Get("/rates/{base}", getRates)
r.Get("/convert", convert)
return &Service{router: r}, nil
}
// Fallback sends every request that no Encore endpoint matches to chi.
//
//encore:api public raw path=/!fallback
func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) {
s.router.ServeHTTP(w, req)
}
// rateLimit is ordinary chi middleware backed by the Redis counter.
func rateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing X-API-Key"})
return
}
now := time.Now()
n, err := requests.Increment(r.Context(), window{APIKey: key, Minute: now.Unix() / 60}, 1)
if err != nil {
// Fail open: a cache outage should not take the API down.
next.ServeHTTP(w, r)
return
}
w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(max(0, limitPerMinute-n), 10))
if n > limitPerMinute {
w.Header().Set("Retry-After", strconv.Itoa(60-now.Second()))
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"})
return
}
next.ServeHTTP(w, r)
})
}
func getRates(w http.ResponseWriter, r *http.Request) {
base := chi.URLParam(r, "base")
if !currency.MatchString(base) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "base must be a currency code like EUR"})
return
}
res, err := loadRates(r, base)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "rates provider unavailable"})
return
}
writeJSON(w, http.StatusOK, res)
}
func convert(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
from, to := q.Get("from"), q.Get("to")
amount, err := strconv.ParseFloat(q.Get("amount"), 64)
if !currency.MatchString(from) || !currency.MatchString(to) || err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from, to and amount are required"})
return
}
res, err := loadRates(r, from)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "rates provider unavailable"})
return
}
rate, ok := res.Rates[to]
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown currency " + to})
return
}
writeJSON(w, http.StatusOK, map[string]any{"from": from, "to": to, "amount": amount, "result": amount * rate, "date": res.Date})
}
// loadRates is cache-aside: read Redis first, call the provider on a miss.
func loadRates(r *http.Request, base string) (Rates, error) {
ctx := r.Context()
cached, err := rates.Get(ctx, base)
if err == nil {
return cached, nil
} else if !errors.Is(err, cache.Miss) {
return Rates{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.frankfurter.dev/v1/latest?base="+base, nil)
if err != nil {
return Rates{}, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return Rates{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Rates{}, fmt.Errorf("provider returned %d", resp.StatusCode)
}
var fresh Rates
if err := json.NewDecoder(resp.Body).Decode(&fresh); err != nil {
return Rates{}, err
}
// A failed cache write only costs one more provider call later.
_ = rates.Set(ctx, base, fresh)
return fresh, nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
encore run starts the app with an in-memory Redis, so the limiter and the cache work on a fresh clone. Running it locally, a request without X-API-Key returns 401, the first GET /rates/EUR calls the provider and later ones within ten minutes come from Redis, and the 61st request from the same key inside one minute gets a 429 with Retry-After. The middleware fails open if Redis errors, so a cache problem slows the API down instead of rejecting every call, and you can flip that to fail closed for endpoints where the limit protects something expensive.
Keyspaces are typed: requests only accepts a window struct, and Encore checks at compile time that every field appears in the key pattern and that no two keyspaces in the cluster share a pattern. On deploy, Encore provisions ElastiCache on AWS or Memorystore on GCP in your own account, with the connection details wired into the service, so there is no REDIS_URL to set per environment.
For coding agents, the cache is a declaration in the same Go file as the middleware, so an agent that adds a per-endpoint limit adds the keyspace and the check together, and encore run exercises both against the local Redis before review.
Best fit: chi APIs that want rate limits and caching shared across every replica, with Redis that runs the same on a laptop and in your own AWS or GCP account.
The hand-built version uses go-redis against an ElastiCache cluster, and its rate limit middleware runs INCR and sets the expiry in one pipeline so a crash between the two calls cannot leave a counter that never expires:
package main
import (
"crypto/tls"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/redis/go-redis/v9"
)
func rateLimit(rdb *redis.Client, limit int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
http.Error(w, "missing X-API-Key", http.StatusUnauthorized)
return
}
key := fmt.Sprintf("ratelimit:%s:%d", apiKey, time.Now().Unix()/60)
pipe := rdb.TxPipeline()
incr := pipe.Incr(r.Context(), key)
pipe.Expire(r.Context(), key, 2*time.Minute)
if _, err := pipe.Exec(r.Context()); err != nil {
next.ServeHTTP(w, r) // fail open
return
}
if incr.Val() > limit {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
func main() {
rdb := redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_ADDR"),
Password: os.Getenv("REDIS_AUTH_TOKEN"),
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12},
})
r := chi.NewRouter()
r.Use(rateLimit(rdb, 60))
r.Get("/rates/{base}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(chi.URLParam(r, "base")))
})
log.Fatal(http.ListenAndServe(":8080", r))
}
The cache-aside helper follows the same shape with rdb.Get, a check for redis.Nil on a miss and rdb.Set(ctx, key, data, 10*time.Minute) after the provider call, with JSON encoding and key formatting written by hand. Locally the TLS config has to be switched off for a plain Redis container. In AWS you create a replication group in private subnets, a subnet group, a security group that allows port 6379 from the app, and turn on in-transit encryption and an auth token, which then has to reach the app through Secrets Manager.
Best fit: teams that already run ElastiCache in Terraform and want direct access to every Redis command and client setting.
httprate is the rate limiter from the chi organization, and for a single instance it is the shortest route: r.Use(httprate.LimitBy(60, time.Minute, keyFunc)) with a key function that returns the X-API-Key header. It uses a sliding window counter kept in process memory, so with three replicas each one allows the full limit on its own, and a deploy resets every counter. The httprate-redis package moves the counters into Redis, which brings back the same provisioning work as the go-redis route, and httprate does nothing for caching.
Best fit: a single-instance chi service that needs basic abuse protection and no shared cache.
Pick Encore when you want the limiter and the cache to share one Redis that runs locally with encore run and on ElastiCache or Memorystore in your own account, with typed keys checked at compile time. Pick go-redis with ElastiCache when the cache is already managed in Terraform and you want the full command set. httprate fits a single instance where a per-process limit is acceptable.
Create one Redis client at startup, for example redis.NewClient from go-redis, and use it from handlers and middleware with r.Context(). chi middleware is a func(http.Handler) http.Handler, so a Redis-backed check such as a rate limit wraps the router with r.Use and runs before every route.
Increment a counter whose key contains the client identifier and the current minute, give it an expiry a little longer than the window, and return 429 with a Retry-After header once the count passes the limit. With Encore that is requests.Increment(ctx, window{APIKey: key, Minute: now.Unix() / 60}, 1) on an IntKeyspace with DefaultExpiry: cache.ExpireIn(2 * time.Minute).
Declare a cluster with cache.NewCluster and typed keyspaces such as cache.NewStructKeyspace, mount the chi router in a raw endpoint with path=/!fallback, and call the keyspaces from chi handlers and middleware. encore run provides an in-memory Redis locally, and deploys get ElastiCache on AWS or Memorystore on GCP in your own account.
httprate keeps counters in memory by default, which is fine for one instance but gives each replica its own limit once you scale out. The httprate-redis package moves the counters into Redis so every replica shares them.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.