Skip to content

Commit 474c9da

Browse files
committed
fix: harden gateway against Riot API deprecations and operational failures
- Replace deprecated summoner-v4/by-name with composite by-riot-id (Account-V1 → PUUID → Summoner-V4); old route returns 410 Gone - Retry 5xx with exponential backoff (0/100ms/500ms) before tripping circuit breaker, absorbing transient Riot instability - Isolate circuit breaker per (region, endpoint) so match failures don't block summoner lookups in the same region - Fix app rate limiter to be a single global instance — per-region limiters allowed N×limit burst with a single API key - Cap L1 cache with LRU eviction (hashicorp/golang-lru, default 10k) to prevent OOM under bulk scraping workloads - Cache 404s in L1 with short TTL to stop repeated misses from hammering the Riot API on invalid/deleted resources - Enforce aud claim in JWT validation to prevent user-facing tokens from authenticating as internal services - Inject version/commit/builtAt into /health via ldflags - Add X-Request-ID middleware for cross-service log correlation
1 parent 665784a commit 474c9da

29 files changed

Lines changed: 2512 additions & 132 deletions

File tree

LICENSE

Lines changed: 661 additions & 0 deletions
Large diffs are not rendered by default.

cmd/server/main.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,28 @@ import (
1616
"prostaff-riot-gateway/internal/circuit"
1717
"prostaff-riot-gateway/internal/config"
1818
"prostaff-riot-gateway/internal/handlers"
19+
"prostaff-riot-gateway/internal/middleware"
1920
"prostaff-riot-gateway/internal/ratelimit"
2021
"prostaff-riot-gateway/internal/riot"
2122
)
2223

24+
// Injected at build time via ldflags:
25+
//
26+
// -ldflags "-X main.version=$(git describe --tags --always) \
27+
// -X main.commit=$(git rev-parse --short HEAD) \
28+
// -X main.builtAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
29+
var (
30+
version = "dev"
31+
commit = "none"
32+
builtAt = "unknown"
33+
)
34+
2335
func main() {
2436
cfg := config.Load()
2537

2638
logger := buildLogger(cfg.LogLevel)
2739

28-
l1 := cache.NewMemory(60 * time.Second)
40+
l1 := cache.NewMemory(60*time.Second, cfg.CacheL1MaxSize)
2941
l2 := cache.NewRedis(cfg.RedisURL, cfg.CacheEnabled, logger)
3042

3143
limiter := ratelimit.NewAppLimiter(cfg.RiotRateLimitPerSecond, cfg.RiotRateLimitBurst, cfg.RiotRateLimitPer2Min)
@@ -37,7 +49,7 @@ func main() {
3749
leagueH := handlers.NewLeagueHandler(riotClient, l1, l2, logger)
3850
matchesH := handlers.NewMatchesHandler(riotClient, l1, l2, logger)
3951
masteryH := handlers.NewMasteryHandler(riotClient, l1, l2, logger)
40-
healthH := handlers.NewHealthHandler(breakers, l2)
52+
healthH := handlers.NewHealthHandler(breakers, l2, version, commit, builtAt)
4153

4254
r := buildRouter(summonerH, leagueH, matchesH, masteryH, healthH, cfg.InternalJWTSecret)
4355

@@ -53,7 +65,7 @@ func main() {
5365
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
5466

5567
go func() {
56-
logger.Info("prostaff-riot-gateway started", "port", cfg.Port)
68+
logger.Info("prostaff-riot-gateway started", "port", cfg.Port, "version", version, "commit", commit)
5769
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
5870
logger.Error("server error", "error", err)
5971
os.Exit(1)
@@ -82,13 +94,16 @@ func buildRouter(
8294
jwtSecret string,
8395
) *mux.Router {
8496
r := mux.NewRouter()
97+
r.Use(middleware.RequestID)
8598

8699
r.HandleFunc("/health", healthH.Handle).Methods(http.MethodGet)
87100

88101
riot := r.PathPrefix("/riot").Subrouter()
89102
riot.Use(auth.InternalAuth(jwtSecret))
90103

91104
riot.HandleFunc("/summoner/{region}/by-puuid/{puuid}", summonerH.ByPUUID).Methods(http.MethodGet)
105+
riot.HandleFunc("/summoner/{region}/by-riot-id/{gameName}/{tagLine}", summonerH.ByRiotID).Methods(http.MethodGet)
106+
// by-name was removed — Riot deprecated summoner-v4/by-name in 2024.
92107
riot.HandleFunc("/summoner/{region}/by-name/{name}", summonerH.ByName).Methods(http.MethodGet)
93108
riot.HandleFunc("/account/{region}/{riotId}/{tagline}", summonerH.AccountByRiotID).Methods(http.MethodGet)
94109
riot.HandleFunc("/account/{region}/by-puuid/{puuid}", summonerH.AccountByPUUID).Methods(http.MethodGet)

docker-compose.production.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ services:
22
gateway:
33
build:
44
context: .
5-
dockerfile: dockerfile
5+
dockerfile: Dockerfile
66
expose:
77
- "4444"
88
restart: unless-stopped

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ services:
22
gateway:
33
build:
44
context: .
5-
dockerfile: dockerfile
5+
dockerfile: Dockerfile
66
ports:
77
- "4444:4444"
88
env_file: .env

dockerfile

Lines changed: 0 additions & 13 deletions
This file was deleted.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ require (
1515
require (
1616
github.com/cespare/xxhash/v2 v2.2.0 // indirect
1717
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
18+
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
1819
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
1010
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
1111
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
1212
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
13+
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
14+
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
1315
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
1416
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
1517
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=

internal/auth/jwt.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,32 @@ import (
66
"github.com/golang-jwt/jwt/v5"
77
)
88

9+
const GatewayAudience = "prostaff-riot-gateway"
10+
911
var ErrInvalidToken = errors.New("invalid or expired token")
1012

11-
// ServiceClaims are the JWT claims used by internal ProStaff services.
1213
type ServiceClaims struct {
1314
Service string `json:"service"`
1415
jwt.RegisteredClaims
1516
}
1617

17-
// ValidateServiceToken parses and validates a JWT issued by an internal service.
18+
// ValidateServiceToken rejects tokens whose aud != GatewayAudience, preventing
19+
// user-facing JWTs from being reused as service tokens even if the secret is shared.
1820
func ValidateServiceToken(tokenString, secret string) (*ServiceClaims, error) {
1921
claims := &ServiceClaims{}
2022

21-
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
22-
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
23-
return nil, errors.New("unexpected signing method")
24-
}
25-
return []byte(secret), nil
26-
})
23+
token, err := jwt.ParseWithClaims(
24+
tokenString,
25+
claims,
26+
func(t *jwt.Token) (interface{}, error) {
27+
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
28+
return nil, errors.New("unexpected signing method")
29+
}
30+
return []byte(secret), nil
31+
},
32+
jwt.WithAudience(GatewayAudience),
33+
jwt.WithExpirationRequired(),
34+
)
2735
if err != nil {
2836
return nil, ErrInvalidToken
2937
}

internal/cache/memory.go

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,74 @@
11
package cache
22

33
import (
4-
"sync"
54
"time"
5+
6+
lru "github.com/hashicorp/golang-lru/v2"
67
)
78

89
type entry struct {
910
data []byte
1011
expiresAt time.Time
1112
}
1213

13-
// Memory is a thread-safe in-process cache with TTL (L1).
1414
type Memory struct {
15-
store sync.Map
15+
cache *lru.Cache[string, entry]
1616
}
1717

18-
func NewMemory(gcInterval time.Duration) *Memory {
19-
m := &Memory{}
18+
func NewMemory(gcInterval time.Duration, maxSize int) *Memory {
19+
c, _ := lru.New[string, entry](maxSize)
20+
m := &Memory{cache: c}
2021
go m.gc(gcInterval)
2122
return m
2223
}
2324

2425
func (m *Memory) Get(key string) ([]byte, bool) {
25-
v, ok := m.store.Load(key)
26+
v, ok := m.cache.Get(key)
2627
if !ok {
2728
return nil, false
2829
}
29-
e := v.(entry)
30-
if time.Now().After(e.expiresAt) {
31-
m.store.Delete(key)
30+
if time.Now().After(v.expiresAt) {
31+
m.cache.Remove(key)
3232
return nil, false
3333
}
34-
return e.data, true
34+
return v.data, true
3535
}
3636

3737
func (m *Memory) Set(key string, data []byte, ttl time.Duration) {
38-
m.store.Store(key, entry{
38+
m.cache.Add(key, entry{
3939
data: data,
4040
expiresAt: time.Now().Add(ttl),
4141
})
4242
}
4343

44+
func (m *Memory) SetNegative(key string, ttl time.Duration) {
45+
m.cache.Add("404:"+key, entry{
46+
data: nil,
47+
expiresAt: time.Now().Add(ttl),
48+
})
49+
}
50+
51+
func (m *Memory) IsNegative(key string) bool {
52+
v, ok := m.cache.Get("404:" + key)
53+
if !ok {
54+
return false
55+
}
56+
if time.Now().After(v.expiresAt) {
57+
m.cache.Remove("404:" + key)
58+
return false
59+
}
60+
return true
61+
}
62+
4463
func (m *Memory) gc(interval time.Duration) {
4564
ticker := time.NewTicker(interval)
4665
defer ticker.Stop()
4766
for range ticker.C {
4867
now := time.Now()
49-
m.store.Range(func(k, v interface{}) bool {
50-
if now.After(v.(entry).expiresAt) {
51-
m.store.Delete(k)
68+
for _, key := range m.cache.Keys() {
69+
if v, ok := m.cache.Peek(key); ok && now.After(v.expiresAt) {
70+
m.cache.Remove(key)
5271
}
53-
return true
54-
})
72+
}
5573
}
5674
}

internal/cache/ttl.go

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,27 @@ type TTL struct {
99

1010
// TTLs defines cache durations for each Riot resource type.
1111
var TTLs = map[string]TTL{
12-
"summoner-by-puuid": {L1: 10 * time.Minute, L2: 10 * time.Minute},
13-
"summoner-by-name": {L1: 5 * time.Minute, L2: 5 * time.Minute},
14-
"account": {L1: time.Hour, L2: time.Hour},
15-
"league-summoner": {L1: 5 * time.Minute, L2: 5 * time.Minute},
16-
"league-puuid": {L1: 5 * time.Minute, L2: 5 * time.Minute},
17-
"match-ids": {L1: 5 * time.Minute, L2: 5 * time.Minute},
18-
"match-detail": {L1: time.Hour, L2: 24 * time.Hour},
19-
"mastery-top": {L1: 30 * time.Minute, L2: time.Hour},
12+
"summoner-by-riot-id": {L1: 10 * time.Minute, L2: 10 * time.Minute},
13+
"summoner-by-puuid": {L1: 10 * time.Minute, L2: 10 * time.Minute},
14+
"summoner-by-name": {L1: 5 * time.Minute, L2: 5 * time.Minute},
15+
"account": {L1: time.Hour, L2: time.Hour},
16+
"league-summoner": {L1: 5 * time.Minute, L2: 5 * time.Minute},
17+
"league-puuid": {L1: 5 * time.Minute, L2: 5 * time.Minute},
18+
"match-ids": {L1: 5 * time.Minute, L2: 5 * time.Minute},
19+
"match-detail": {L1: time.Hour, L2: 24 * time.Hour},
20+
"mastery-top": {L1: 30 * time.Minute, L2: time.Hour},
21+
}
22+
23+
// NegativeTTLs defines how long a confirmed 404 is cached in L1 per resource type.
24+
// Only cached in L1 (not L2) since these are short-lived and service-specific.
25+
var NegativeTTLs = map[string]time.Duration{
26+
"summoner-by-riot-id": 30 * time.Second,
27+
"summoner-by-puuid": 2 * time.Minute,
28+
"summoner-by-name": 30 * time.Second,
29+
"account": 2 * time.Minute,
30+
"league-summoner": 30 * time.Second,
31+
"league-puuid": 30 * time.Second,
32+
"match-ids": 30 * time.Second,
33+
"match-detail": 5 * time.Minute,
34+
"mastery-top": 30 * time.Second,
2035
}

0 commit comments

Comments
 (0)