Cache slow upstream calls and rate limit clients in a Gin API with Redis, using Encore's typed cache keyspaces or go-redis with ElastiCache.
A Gin service usually reaches for Redis for one of two reasons: an upstream call that is too slow or too rate limited to make on every request, or a public endpoint that needs a per-client limit shared across all replicas. Gin has no opinion on either, so the work lands in picking a client, running Redis next to the app on every laptop and getting a managed Redis into the same private network as the service in the cloud.
This guide adds both to a Gin API that serves GitHub repository stats. Responses from the GitHub API are cached for ten minutes, and each client IP is limited to 30 requests per minute. The code is shown with Encore, which ran it with encore run for this article, and with go-redis against Amazon ElastiCache.
Encore combines an open source infrastructure SDK with a platform that provisions and deploys into your own AWS or GCP account. Gin keeps its router and middleware, mounted in one raw endpoint with a fallback route, and the Redis cluster and its keyspaces are declared in the same package as the handlers that use them.
Run in your terminal to get started locally.
A keyspace fixes the key pattern, the value type and the default expiry, so a cached repository can only be read back as RepoStats, and two keyspaces in the same cluster cannot collide on a key. The rate limit middleware is a regular gin.HandlerFunc that increments a counter per client IP:
// repos/repos.go
package repos
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"encore.dev/storage/cache"
"github.com/gin-gonic/gin"
)
// One Redis cluster for the app: in-memory with `encore run`,
// ElastiCache on AWS and Memorystore on GCP when deployed.
var cluster = cache.NewCluster("app-cache", cache.ClusterConfig{
EvictionPolicy: cache.AllKeysLRU,
})
type RepoStats struct {
FullName string `json:"full_name"`
Stars int `json:"stargazers_count"`
Forks int `json:"forks_count"`
}
type repoKey struct {
Owner string
Name string
}
// Cached GitHub responses live for 10 minutes.
var repoCache = cache.NewStructKeyspace[repoKey, RepoStats](cluster, cache.KeyspaceConfig{
KeyPattern: "repo/:Owner/:Name",
DefaultExpiry: cache.ExpireIn(10 * time.Minute),
})
// Requests per client IP, counted in one-minute windows.
var requestsPerIP = cache.NewIntKeyspace[string](cluster, cache.KeyspaceConfig{
KeyPattern: "ratelimit/:key",
DefaultExpiry: cache.ExpireIn(time.Minute),
})
//encore:service
type Service struct {
router *gin.Engine
}
func initService() (*Service, error) {
r := gin.New()
r.Use(gin.Recovery(), rateLimit(30))
r.GET("/repos/:owner/:name", getRepo)
return &Service{router: r}, nil
}
//encore:api public raw path=/!fallback
func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) {
s.router.ServeHTTP(w, req)
}
func rateLimit(perMinute int64) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := requestsPerIP.Increment(c.Request.Context(), c.ClientIP(), 1)
// Fail open: if Redis is unreachable, serve the request.
if err == nil && n > perMinute {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
return
}
c.Next()
}
}
func getRepo(c *gin.Context) {
ctx := c.Request.Context()
key := repoKey{Owner: c.Param("owner"), Name: c.Param("name")}
stats, err := repoCache.Get(ctx, key)
if err == nil {
c.Header("X-Cache", "hit")
c.JSON(http.StatusOK, stats)
return
} else if !errors.Is(err, cache.Miss) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cache unavailable"})
return
}
stats, err = fetchFromGitHub(ctx, key)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
_ = repoCache.Set(ctx, key, stats)
c.Header("X-Cache", "miss")
c.JSON(http.StatusOK, stats)
}
func fetchFromGitHub(ctx context.Context, key repoKey) (RepoStats, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s", key.Owner, key.Name)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return RepoStats{}, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return RepoStats{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return RepoStats{}, fmt.Errorf("github returned %d", resp.StatusCode)
}
var stats RepoStats
err = json.NewDecoder(resp.Body).Decode(&stats)
return stats, err
}
With encore run, the first curl -i localhost:4000/repos/encoredev/encore returns X-Cache: miss and the second returns X-Cache: hit without calling GitHub, and the 31st request inside a minute gets a 429. Locally Encore runs an in-memory Redis that keeps a small number of keys and purges at random beyond that, so treat it as a cache that can lose entries, which is the right assumption in production too.
On deploy, Encore provisions ElastiCache for Redis on AWS or Memorystore on GCP inside the environment's private network, and connects the service to it with credentials it manages, so there is no REDIS_URL or TLS flag to keep in sync between environments. When an AI coding agent adds a second cached endpoint, it declares a new keyspace with its own KeyPattern in the same file, and the build fails if that pattern overlaps one that exists. The same service can add Postgres behind the cache with one more declaration.
Best fit: Gin APIs that need a shared cache or rate limit across replicas, typed keys and values, and Redis provisioned in your own AWS or GCP account.
go-redis is the standard Redis client for Go, and the same cache and limiter written with it encode the struct as JSON by hand and set the expiry on the first increment, which gives a fixed one-minute window:
func newRedis() *redis.Client {
opts := &redis.Options{Addr: os.Getenv("REDIS_ADDR")}
// Local Docker Redis is plaintext. Production sets REDIS_TLS=true and
// loads the ACL user from Secrets Manager. The password is not logged.
if os.Getenv("REDIS_TLS") == "true" {
opts.Username = os.Getenv("REDIS_USERNAME")
opts.Password = os.Getenv("REDIS_PASSWORD")
opts.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
return redis.NewClient(opts)
}
var rdb = newRedis()
func rateLimit(perMinute int64) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
key := "ratelimit:" + c.ClientIP()
n, err := rdb.Incr(ctx, key).Result()
if err == nil && n == 1 {
rdb.Expire(ctx, key, time.Minute)
}
if err == nil && n > perMinute {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
return
}
c.Next()
}
}
func cachedRepo(ctx context.Context, owner, name string) (RepoStats, error) {
key := "repo:" + owner + "/" + name
if b, err := rdb.Get(ctx, key).Bytes(); err == nil {
var s RepoStats
return s, json.Unmarshal(b, &s)
} else if !errors.Is(err, redis.Nil) {
return RepoStats{}, err
}
s, err := fetchFromGitHub(ctx, repoKey{Owner: owner, Name: name})
if err != nil {
return RepoStats{}, err
}
b, _ := json.Marshal(s)
return s, rdb.Set(ctx, key, b, 10*time.Minute).Err()
}
On AWS, an ElastiCache cluster lives in private subnets, so it needs a cache subnet group, a security group that allows port 6379 from the Gin service, in-transit encryption if you want TLS, and an auth token or ACL user stored in Secrets Manager. It cannot be reached from a laptop, so local development runs Redis in Docker with TLS switched off. The client enables TLS, a username and a password only when REDIS_TLS is true, and those credentials come from Secrets Manager rather than the command line. If the Expire call after Incr fails, the counter never resets, which is why production limiters often wrap both in a MULTI block or a Lua script.
Best fit: Gin services whose Redis is already provisioned in Terraform, or that need commands such as streams, pub/sub channels or Lua scripts that typed keyspaces do not cover.
For a single replica, an in-memory cache such as ristretto avoids Redis entirely and is faster than any network call. It stops working as a rate limiter once there are two replicas, because each one counts separately, and every deploy starts with an empty cache. It is a good first layer in front of Redis for very hot keys and a poor replacement for it.
Best fit: single-instance services and hot keys where a few seconds of staleness per replica is acceptable.
Pick Encore when you want to keep Gin and get a Redis cache and rate limit that run under encore run, with typed keyspaces and ElastiCache or Memorystore provisioned in your own cloud account. Pick go-redis with ElastiCache when Redis is managed in existing Terraform or you need commands beyond keyspaces. An in-process cache only fits until the second replica.
Create one Redis client at startup, make it reachable from your handlers or middleware, and call it with c.Request.Context(). Typical uses are cache-aside reads in front of a slow database or API, and a rate limit middleware that increments a counter per client and aborts with 429 once the counter passes the limit.
Write a gin.HandlerFunc that runs INCR on a key such as ratelimit:<client-ip>, sets an expiry equal to the window on the first increment, and calls c.AbortWithStatusJSON(429, ...) when the count is over the limit. Storing the counter in Redis keeps the limit correct across every replica.
Declare a cluster with cache.NewCluster("app-cache", cache.ClusterConfig{EvictionPolicy: cache.AllKeysLRU}) and a typed keyspace such as cache.NewStructKeyspace[repoKey, RepoStats](cluster, cache.KeyspaceConfig{KeyPattern: "repo/:Owner/:Name", DefaultExpiry: cache.ExpireIn(10 * time.Minute)}), then call Get and Set from your Gin handlers. encore run provides an in-memory Redis locally, and Encore provisions ElastiCache on AWS or Memorystore on GCP when you deploy.
Public read endpoints usually fail open so a cache outage does not take the API down. Login, signup and payment endpoints that the limit protects against abuse are safer failing closed with a 503.
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.