Skip to content

Commit 98b5c5e

Browse files
committed
feat: implement circuit breaker
1 parent 56b5a0e commit 98b5c5e

5 files changed

Lines changed: 50 additions & 54 deletions

File tree

cmd/server/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func main() {
2828
l1 := cache.NewMemory(60 * time.Second)
2929
l2 := cache.NewRedis(cfg.RedisURL, cfg.CacheEnabled, logger)
3030

31-
limiter := ratelimit.NewRegionLimiter(cfg.RiotRateLimitPerSecond, cfg.RiotRateLimitBurst)
31+
limiter := ratelimit.NewAppLimiter(cfg.RiotRateLimitPerSecond, cfg.RiotRateLimitBurst, cfg.RiotRateLimitPer2Min)
3232
breakers := circuit.NewRegionBreakers(cfg.CBThreshold, cfg.CBTimeout, cfg.CBCooldown)
3333

3434
riotClient := riot.NewClient(cfg.RiotAPITimeout, cfg.RiotAPIKey, limiter, breakers, logger)

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type Config struct {
1616
RiotAPITimeout time.Duration
1717
RiotRateLimitPerSecond float64
1818
RiotRateLimitBurst int
19+
RiotRateLimitPer2Min int
1920
RedisURL string
2021
CacheEnabled bool
2122
CBThreshold int
@@ -46,6 +47,7 @@ func Load() *Config {
4647
RiotAPITimeout: getDuration("RIOT_API_TIMEOUT", 5*time.Second),
4748
RiotRateLimitPerSecond: getFloat("RIOT_RATE_LIMIT_PER_SECOND", 20.0),
4849
RiotRateLimitBurst: getInt("RIOT_RATE_LIMIT_BURST", 20),
50+
RiotRateLimitPer2Min: getInt("RIOT_RATE_LIMIT_PER_2MIN", 100),
4951
RedisURL: getEnv("REDIS_URL", "redis://localhost:6379/1"),
5052
CacheEnabled: getBool("CACHE_ENABLED", true),
5153
CBThreshold: getInt("CIRCUIT_BREAKER_THRESHOLD", 5),

internal/handlers/base.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package handlers
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"log/slog"
78
"net/http"
@@ -49,6 +50,10 @@ func (d *deps) fetch(
4950
data, status, err := d.riot.Do(r.Context(), region, riotPath)
5051
if err != nil {
5152
d.logger.Warn("riot call failed", "region", region, "path", riotPath, "status", status, "error", err)
53+
var rlErr *riot.RateLimitError
54+
if errors.As(err, &rlErr) && rlErr.RetryAfter != "" {
55+
w.Header().Set("Retry-After", rlErr.RetryAfter)
56+
}
5257
webutils.ErrorJSON(w, err, status)
5358
return
5459
}

internal/ratelimit/limiter.go

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package ratelimit
22

33
import (
44
"context"
5-
"sync"
65

76
"golang.org/x/time/rate"
87
)
@@ -41,43 +40,32 @@ var ServerToRouting = map[string]string{
4140
"oc1": "sea",
4241
}
4342

44-
// RegionLimiter holds one token bucket per Riot region.
45-
type RegionLimiter struct {
46-
mu sync.RWMutex
47-
limiters map[string]*rate.Limiter
48-
rps float64
49-
burst int
43+
// AppLimiter enforces the Riot app-level rate limit globally across all regions.
44+
// It combines two token buckets to cover both the per-second and per-2-minute windows
45+
// that Riot enforces on every API key.
46+
type AppLimiter struct {
47+
perSecond *rate.Limiter
48+
per2Min *rate.Limiter
5049
}
5150

52-
func NewRegionLimiter(rps float64, burst int) *RegionLimiter {
53-
return &RegionLimiter{
54-
limiters: make(map[string]*rate.Limiter),
55-
rps: rps,
56-
burst: burst,
51+
// NewAppLimiter creates an AppLimiter with independent per-second and per-2-minute buckets.
52+
// rps and burst configure the 1-second window; per2Min configures the 2-minute window.
53+
func NewAppLimiter(rps float64, burst int, per2Min int) *AppLimiter {
54+
return &AppLimiter{
55+
perSecond: rate.NewLimiter(rate.Limit(rps), burst),
56+
// Riot's 2-min limit modelled as a token bucket: refill rate = per2Min/120s, burst = per2Min.
57+
// This is a safe approximation: it prevents bursting past the 2-min cap while
58+
// allowing the full burst at startup (matching Riot's sliding-window behaviour).
59+
per2Min: rate.NewLimiter(rate.Limit(float64(per2Min)/120.0), per2Min),
5760
}
5861
}
5962

60-
// Wait blocks until a token is available for the given region, or ctx is cancelled.
61-
func (rl *RegionLimiter) Wait(ctx context.Context, region string) error {
62-
return rl.get(region).Wait(ctx)
63-
}
64-
65-
func (rl *RegionLimiter) get(region string) *rate.Limiter {
66-
rl.mu.RLock()
67-
l, ok := rl.limiters[region]
68-
rl.mu.RUnlock()
69-
if ok {
70-
return l
71-
}
72-
73-
rl.mu.Lock()
74-
defer rl.mu.Unlock()
75-
if l, ok = rl.limiters[region]; ok {
76-
return l
63+
// Wait blocks until both rate windows have capacity, or ctx is cancelled.
64+
func (al *AppLimiter) Wait(ctx context.Context) error {
65+
if err := al.perSecond.Wait(ctx); err != nil {
66+
return err
7767
}
78-
l = rate.NewLimiter(rate.Limit(rl.rps), rl.burst)
79-
rl.limiters[region] = l
80-
return l
68+
return al.per2Min.Wait(ctx)
8169
}
8270

8371
// ValidRegion returns true if the region is in the allowed list.

internal/riot/client.go

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,29 @@ import (
1212
"prostaff-riot-gateway/internal/ratelimit"
1313
)
1414

15+
// RateLimitError is returned when Riot responds with 429. It carries the Retry-After
16+
// value from the Riot header so callers can propagate it to their own clients.
17+
type RateLimitError struct {
18+
RetryAfter string
19+
}
20+
21+
func (e *RateLimitError) Error() string {
22+
return fmt.Sprintf("riot api rate limited, retry after %s seconds", e.RetryAfter)
23+
}
24+
1525
// Client calls the Riot Games API with rate limiting and circuit breaking.
1626
type Client struct {
1727
http *http.Client
1828
apiKey string
19-
limiter *ratelimit.RegionLimiter
29+
limiter *ratelimit.AppLimiter
2030
breakers *circuit.RegionBreakers
2131
logger *slog.Logger
2232
}
2333

2434
func NewClient(
2535
timeout time.Duration,
2636
apiKey string,
27-
limiter *ratelimit.RegionLimiter,
37+
limiter *ratelimit.AppLimiter,
2838
breakers *circuit.RegionBreakers,
2939
logger *slog.Logger,
3040
) *Client {
@@ -51,7 +61,7 @@ func (c *Client) Do(ctx context.Context, region, path string) ([]byte, int, erro
5161
return nil, http.StatusServiceUnavailable, fmt.Errorf("riot api circuit open for region %s", region)
5262
}
5363

54-
if err := c.limiter.Wait(ctx, region); err != nil {
64+
if err := c.limiter.Wait(ctx); err != nil {
5565
return nil, http.StatusGatewayTimeout, fmt.Errorf("rate limit wait cancelled: %w", err)
5666
}
5767

@@ -76,12 +86,17 @@ func (c *Client) Do(ctx context.Context, region, path string) ([]byte, int, erro
7686
return nil, http.StatusBadGateway, fmt.Errorf("failed to read riot response: %w", err)
7787
}
7888

79-
status := mapRiotStatus(resp.StatusCode)
89+
// 429 is a rate limit signal, not an infrastructure failure — do not trip the circuit breaker.
90+
if resp.StatusCode == http.StatusTooManyRequests {
91+
retryAfter := resp.Header.Get("Retry-After")
92+
c.logger.Warn("riot api rate limited", "region", region, "retry_after", retryAfter)
93+
return nil, http.StatusTooManyRequests, &RateLimitError{RetryAfter: retryAfter}
94+
}
8095

81-
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
96+
if resp.StatusCode >= 500 {
8297
breaker.RecordFailure()
83-
c.logger.Warn("riot api returned error", "region", region, "riot_status", resp.StatusCode)
84-
return nil, status, fmt.Errorf("riot api returned %d", resp.StatusCode)
98+
c.logger.Warn("riot api server error", "region", region, "riot_status", resp.StatusCode)
99+
return nil, http.StatusBadGateway, fmt.Errorf("riot api returned %d", resp.StatusCode)
85100
}
86101

87102
if resp.StatusCode == http.StatusNotFound {
@@ -92,17 +107,3 @@ func (c *Client) Do(ctx context.Context, region, path string) ([]byte, int, erro
92107
return body, http.StatusOK, nil
93108
}
94109

95-
func mapRiotStatus(riotStatus int) int {
96-
switch {
97-
case riotStatus == http.StatusOK:
98-
return http.StatusOK
99-
case riotStatus == http.StatusNotFound:
100-
return http.StatusNotFound
101-
case riotStatus == 429:
102-
return 429
103-
case riotStatus >= 500:
104-
return http.StatusBadGateway
105-
default:
106-
return riotStatus
107-
}
108-
}

0 commit comments

Comments
 (0)