Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ log aggregation systems while keeping HTTP and application concerns separate.
do their own bounded-TTL caching client-side - a second cache layer here would only add
staleness without a clear benefit for this service's read volume.

## Rate limiting

Per-client/IP rate limiting is on by default via the shared `api-core.go/ratelimit` middleware
(Redis-backed counters keyed by `X-API-Key` else client IP, `cheap` tier for `/status/*`,
fail-closed 503 when Redis is unreachable, 429 on exceed). This replaced the process-wide
`rate.NewLimiter` bucket, which one busy caller could exhaust for everyone. `REDIS_HOST`/
`REDIS_PORT` are in the dev configmap; `REDIS_PASS` comes from the `api-cache` `ExternalSecret`.
Tune with `RATE_LIMIT_CHEAP`/`RATE_LIMIT_CHEAP_WINDOW_SECONDS`/`RATE_LIMIT_STANDARD`/
`RATE_LIMIT_STANDARD_WINDOW_SECONDS`. See `platform`'s
`openspec/changes/fix-rate-limiting-per-client-ip`.

## Committing Code

Use [Conventional Commits](https://www.conventionalcommits.org/):
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@

## 0.12.0 - 2026-09-09

### Added
- Replace process-wide bucket with shared api-core middleware (#106)



## 0.11.0 - 2026-09-07

### Added
Expand Down
37 changes: 15 additions & 22 deletions cmd/admin-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ package main

import (
"context"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
Expand All @@ -27,14 +25,14 @@ import (
"github.com/sweetrpg/admin-api/server"
apiconstants "github.com/sweetrpg/api-core.go/constants"
"github.com/sweetrpg/api-core.go/featureflags"
"github.com/sweetrpg/api-core.go/ratelimit"
"github.com/sweetrpg/api-core.go/tracing"
"github.com/sweetrpg/api-core.go/vo"
apiutil "github.com/sweetrpg/api-core.go/util"
"github.com/sweetrpg/authz-client.go/authz"
"github.com/sweetrpg/common.go/logging"
"github.com/sweetrpg/common.go/util"
"github.com/sweetrpg/mongodb.go/database"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"golang.org/x/time/rate"
)

// @title Admin API service
Expand Down Expand Up @@ -98,8 +96,19 @@ func main() {
// Swagger
setupSwagger(r)

// Add rate limiter
r.Use(RateLimiter())
// Per-client/IP rate limiter (Redis-backed, fail-closed). Replaces the process-wide bucket.
redisPool := apiutil.RedisPool()
if redisPool != nil {
pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := ratelimit.Ping(pingCtx, redisPool); err != nil {
logging.Logger.Error("REDIS_HOST is configured but unreachable at startup; rate-limited requests will fail closed until it recovers",
"error", err.Error())
}
cancel()
} else {
logging.Logger.Warn("REDIS_HOST is not configured; rate limiting will fail closed (503) on every limited request")
}
r.Use(ratelimit.Middleware(redisPool, ratelimit.DefaultOptions()))

authzClient := authz.NewClient(util.GetEnv(constants.AUTH_API_URL, ""), util.GetEnv(constants.USERS_API_URL, ""))
server.SetupHandlers(r, authzClient)
Expand Down Expand Up @@ -250,19 +259,3 @@ func setupMetrics(r *gin.Engine) {
m.SetDuration([]float64{0.1, 0.3, 1.2, 5, 10})
m.Use(r)
}

func RateLimiter() gin.HandlerFunc {
limiter := rate.NewLimiter(1, util.GetEnvInt(apiconstants.RATE_LIMIT, 10))

return func(c *gin.Context) {
if limiter.Allow() {
c.Next()
} else {
logging.Logger.Warn(fmt.Sprintf("Rate limit exceeded for request: %v", c.Request))
c.JSON(http.StatusTooManyRequests, vo.ErrorVO{
Error: apiconstants.ErrorRateLimited,
Message: "Limit exceeded",
})
}
}
}
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.26.5

require (
github.com/getsentry/sentry-go v0.49.0
github.com/gin-contrib/cors v1.7.7
github.com/gin-contrib/cors v1.7.8
github.com/gin-gonic/gin v1.12.0
github.com/grafana/pyroscope-go v1.4.2
github.com/joho/godotenv v1.5.1
Expand All @@ -14,15 +14,14 @@ require (
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
github.com/sweetrpg/api-core.go v0.1.1
github.com/sweetrpg/api-core.go v0.3.1
github.com/sweetrpg/authz-client.go v0.2.0
github.com/sweetrpg/common.go v0.0.16
github.com/sweetrpg/mongodb.go v0.0.193
go.mongodb.org/mongo-driver v1.17.9
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.71.0
go.opentelemetry.io/otel v1.46.0
go.opentelemetry.io/otel/trace v1.46.0
golang.org/x/time v0.15.0
)

require (
Expand Down Expand Up @@ -57,6 +56,7 @@ require (
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/gomodule/redigo v1.9.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
Expand Down
16 changes: 10 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.2.0 h1:Kn4yilvwNtMACtf1eYDlG8H77R07mZSPbMjLyS07ChA=
Expand Down Expand Up @@ -45,8 +47,8 @@ github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/getsentry/sentry-go v0.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s=
github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI=
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
github.com/gin-contrib/cors v1.7.8 h1:U0jjyXlWXMEx27hFE6hmdg/TOZviYgsOGGT+kLmUZrc=
github.com/gin-contrib/cors v1.7.8/go.mod h1:u3nLI2pP1IlJn7tbvL7iiubDO9quBZ8FXP9LxmqpsPI=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
Expand Down Expand Up @@ -89,6 +91,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
Expand Down Expand Up @@ -210,8 +214,8 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/sweetrpg/api-core.go v0.1.1 h1:qqr5Rgiaw7n2RtDZ7fdoy5vw8RT3L42orhJ/x+EYX9w=
github.com/sweetrpg/api-core.go v0.1.1/go.mod h1:ka2gLYUlukRaBXiaaohfRSHoNH77oSrTkkik07Z+lj0=
github.com/sweetrpg/api-core.go v0.3.1 h1:Fa0dzFjoohTGLt182pO55kksScGNKi6nFHRij/68GTw=
github.com/sweetrpg/api-core.go v0.3.1/go.mod h1:Li4x24U0q67096dbvOm1Fuq/xOMG1elcgiuJAV8WTJA=
github.com/sweetrpg/authz-client.go v0.2.0 h1:HYzR872fxO/JvSgdWPvdc67bI/5OgmbNNHrGb1QOMX0=
github.com/sweetrpg/authz-client.go v0.2.0/go.mod h1:D5v5R+rW71cy298b7dMaAPTtWpkAntqGMFN3vQdkaSA=
github.com/sweetrpg/common.go v0.0.16 h1:XT8PwUXz0BOMIITXLjw3G6rEjjRUEs0Xs4+R9o2fVBk=
Expand All @@ -233,6 +237,8 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zerodha/logf v0.5.5 h1:AhxHlixHNYwhFjvlgTv6uO4VBKYKxx2I6SbHoHtWLBk=
github.com/zerodha/logf v0.5.5/go.mod h1:HWpfKsie+WFFpnUnUxelT6Z0FC6xu9+qt+oXNMPg6y8=
go.jtlabs.io/query v1.6.1 h1:AldVImTH2RR5M9h8fCdZNyN7bew5JMVuDPelUQnP0DE=
Expand Down Expand Up @@ -321,8 +327,6 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
Expand Down
2 changes: 2 additions & 0 deletions kubernetes/base/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ spec:
name: api-db
- secretRef:
name: api-misc
- secretRef:
name: api-cache
env:
- name: GOMEMLIMIT
value: "900MiB"
Expand Down
9 changes: 9 additions & 0 deletions kubernetes/overlays/dev/configmaps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,12 @@ data:
SENTRY_RELEASE: "1"
AUTH_API_URL: http://api-v1.sweetrpg-auth.svc.cluster.local:8000
USERS_API_URL: http://api-v1.sweetrpg-users.svc.cluster.local:8000
# Per-client/IP rate limiting (platform rate-limiting capability, #106). api-core defaults.
RATE_LIMIT_CHEAP: "120"
RATE_LIMIT_CHEAP_WINDOW_SECONDS: "60"
RATE_LIMIT_STANDARD: "30"
RATE_LIMIT_STANDARD_WINDOW_SECONDS: "60"
# Namespace cache Service, backs the distributed rate-limit counters. REDIS_PASS comes from an
# ExternalSecret (#106).
REDIS_HOST: cache.sweetrpg-admin.svc.cluster.local
REDIS_PORT: "6379"
1 change: 1 addition & 0 deletions kubernetes/overlays/dev/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ resources:
- middlewares.yaml
- password.yaml
- pod-monitor.yaml
- secrets/cache.yaml
- secrets/db-creds.yaml
- secrets/db.yaml
- secrets/misc.yaml
Expand Down
20 changes: 20 additions & 0 deletions kubernetes/overlays/dev/secrets/cache.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret

metadata:
name: admin-api-cache

spec:
refreshInterval: 12h
secretStoreRef:
name: akeyless
kind: ClusterSecretStore
target:
name: api-cache
creationPolicy: Owner
dataFrom:
# Provides REDIS_PASS for the api-core.go/ratelimit Redis pool (Valkey ACL auth is on in
# every namespace). Mirrors catalog-api's secrets/cache.yaml. See platform #106.
- extract:
key: /sweetrpg/admin/cache/auth
Loading