-
Notifications
You must be signed in to change notification settings - Fork 0
fix(server): share rate limits across all replicas #455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| //go:build integration | ||
|
|
||
| package server | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "strings" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations" | ||
| "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/testhelpers" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestApplicationRateLimiter(t *testing.T) { | ||
| for _, key := range []string{ | ||
| "CREDENTIAL_ENCRYPTION_KEY_SECRET_ARN", "CREDENTIAL_ENCRYPTION_KEY_SECRET_NAME", | ||
| "CREDENTIAL_ENCRYPTION_KEY_SECRET_ID", "CREDENTIAL_ENCRYPTION_ALLOW_DEV_KEY", | ||
| "CUDLY_SIGNING_KEY_ID", "CUDLY_SIGNING_KEY_VAULT_URL", "CUDLY_SIGNING_KEY_NAME", | ||
| "CUDLY_SIGNING_KEY_RESOURCE", "AWS_PROFILE", "AWS_LAMBDA_RUNTIME_API", | ||
| } { | ||
| t.Setenv(key, "") | ||
| } | ||
| t.Setenv("CREDENTIAL_ENCRYPTION_KEY", strings.Repeat("12", 32)) | ||
| t.Setenv("CUDLY_ISSUER_URL", "https://rate-limit.example.test") | ||
| t.Setenv("CUDLY_SOURCE_CLOUD", "aws") | ||
| t.Setenv("SCHEDULED_TASK_AUTH_MODE", "disabled") | ||
| t.Setenv("AWS_EC2_METADATA_DISABLED", "true") | ||
| t.Setenv("AWS_ACCESS_KEY_ID", "local-rate-limit-test") | ||
| t.Setenv("AWS_SECRET_ACCESS_KEY", "local-rate-limit-test") | ||
| t.Setenv("AWS_REGION", "us-east-1") | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) | ||
| defer cancel() | ||
| pg, err := testhelpers.SetupPostgresContainer(ctx, t) | ||
| require.NoError(t, err) | ||
| t.Cleanup(func() { require.NoError(t, pg.Cleanup(context.Background())) }) | ||
| require.NoError(t, migrations.RunMigrations(ctx, pg.DB.Pool(), "../database/postgres/migrations", "", "")) | ||
|
|
||
| for mode, isLambda := range []bool{false, true} { | ||
| t.Run(fmt.Sprintf("lambda=%t", isLambda), func(t *testing.T) { | ||
| cfg := ApplicationConfig{ | ||
| IsLambda: isLambda, DashboardURL: "https://rate-limit.example.test", | ||
| DefaultTerm: 3, DefaultCoverage: 80, | ||
| } | ||
| newApp := func() *Application { | ||
| app, appErr := NewApplicationFromDeps(ctx, cfg, ExternalDeps{ | ||
| DBConfig: pg.Config, EmailSender: &noopEmailSender{}, | ||
| }) | ||
| require.NoError(t, appErr) | ||
| t.Cleanup(func() { require.NoError(t, app.Close()) }) | ||
| return app | ||
| } | ||
| apps := []*Application{newApp(), newApp()} | ||
| servers := make([]*httptest.Server, len(apps)) | ||
| for i, app := range apps { | ||
| servers[i] = httptest.NewServer(CreateHTTPServer(app, 0).Handler) | ||
| t.Cleanup(servers[i].Close) | ||
| } | ||
| client := &http.Client{Timeout: 10 * time.Second} | ||
| ip := func(bucket int) string { return fmt.Sprintf("192.0.2.%d", mode*10+bucket) } | ||
| request := func(replica int, path, sourceIP, body string) (int, error) { | ||
| method := http.MethodGet | ||
| if path == "/api/auth/login" { | ||
| method = http.MethodPost | ||
| } | ||
| req, reqErr := http.NewRequestWithContext(ctx, method, servers[replica].URL+path, strings.NewReader(body)) | ||
| if reqErr != nil { | ||
| return 0, reqErr | ||
| } | ||
| req.Header.Set("X-Forwarded-For", sourceIP) | ||
| req.Header.Set("Content-Type", "application/json") | ||
| resp, reqErr := client.Do(req) | ||
| if reqErr != nil { | ||
| return 0, reqErr | ||
| } | ||
| defer resp.Body.Close() | ||
| _, reqErr = io.Copy(io.Discard, resp.Body) | ||
| return resp.StatusCode, reqErr | ||
| } | ||
| checkRequest := func(replica int, path, sourceIP string, want int) { | ||
| t.Helper() | ||
| body := "" | ||
| if path == "/api/auth/login" { | ||
| body = `{"email":"absent@example.test","password":"d3JvbmctcGFzc3dvcmQ="}` | ||
| } | ||
| status, reqErr := request(replica, path, sourceIP, body) | ||
| require.NoError(t, reqErr) | ||
| assert.Equal(t, want, status, "replica=%d path=%s source=%s", replica, path, sourceIP) | ||
| } | ||
| checkCount := func(sourceIP, endpoint string, want int) { | ||
| t.Helper() | ||
| var count int | ||
| err = pg.DB.Pool().QueryRow(ctx, "SELECT count FROM rate_limits WHERE id = $1", | ||
| "IP#"+sourceIP+"#ENDPOINT#"+endpoint).Scan(&count) | ||
| assert.NoError(t, err) | ||
| assert.Equal(t, want, count) | ||
| } | ||
|
|
||
| for i := range 7 { | ||
| want := http.StatusUnauthorized | ||
| if i >= 5 { | ||
| want = http.StatusTooManyRequests | ||
| } | ||
| checkRequest(i%2, "/api/auth/login", ip(1), want) | ||
| } | ||
| checkCount(ip(1), "login", 7) | ||
| checkRequest(1, "/api/auth/login", ip(2), http.StatusUnauthorized) | ||
| checkCount(ip(2), "login", 1) | ||
|
|
||
| for i := range 32 { | ||
| action := "approve" | ||
| if i%2 == 1 { | ||
| action = "cancel" | ||
| } | ||
| want := http.StatusNotFound | ||
| if i >= 30 { | ||
| want = http.StatusTooManyRequests | ||
| } | ||
| checkRequest(i%2, "/api/purchases/"+action+"/00000000-0000-4000-8000-000000000109?token=invalid", ip(3), want) | ||
| } | ||
| checkCount(ip(3), "approve_cancel_public", 32) | ||
|
|
||
| type outcome struct { | ||
| status int | ||
| err error | ||
| } | ||
| results := make(chan outcome, 12) | ||
| for i := range 12 { | ||
| go func() { | ||
| status, reqErr := request(i%2, "/api/auth/login", ip(4), "{") | ||
| results <- outcome{status, reqErr} | ||
| }() | ||
| } | ||
| statuses := make(map[int]int) | ||
| for range 12 { | ||
| result := <-results | ||
| require.NoError(t, result.err) | ||
| statuses[result.status]++ | ||
| } | ||
| assert.Equal(t, map[int]int{http.StatusBadRequest: 5, http.StatusTooManyRequests: 7}, statuses) | ||
| checkCount(ip(4), "login", 12) | ||
|
|
||
| apps[0].DB.Close() | ||
| checkRequest(0, "/api/auth/login", ip(5), http.StatusServiceUnavailable) | ||
| checkRequest(1, "/api/auth/login", ip(5), http.StatusUnauthorized) | ||
| checkCount(ip(5), "login", 1) | ||
|
|
||
| cold := newApp() | ||
| canceled, stop := context.WithCancel(ctx) | ||
| stop() | ||
| req := httptest.NewRequestWithContext(canceled, http.MethodPost, "/api/auth/login", strings.NewReader(`{}`)) | ||
| req.Header.Set("X-Forwarded-For", ip(6)) | ||
| response := httptest.NewRecorder() | ||
| CreateHTTPServer(cold, 0).Handler.ServeHTTP(response, req) | ||
| assert.Equal(t, http.StatusServiceUnavailable, response.Code) | ||
| assert.False(t, cold.dbConnected) | ||
| var count int | ||
| require.NoError(t, pg.DB.Pool().QueryRow(ctx, "SELECT COUNT(*) FROM rate_limits WHERE id = $1", | ||
| "IP#"+ip(6)+"#ENDPOINT#login").Scan(&count)) | ||
| assert.Zero(t, count) | ||
| }) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: LeanerCloud/cloud-commitments-platform
Length of output: 14901
🏁 Script executed:
Repository: LeanerCloud/cloud-commitments-platform
Length of output: 15001
Use an application-lifetime context for the non-Lambda cleanup worker.
StartCleanupWorker(ctx)receives the first HTTP request context. HTTP cancels that context when the request ends, so the worker exits before its 10-minute ticker runs. The remainingmaybeCleanuppath is opportunistic and does not run for perpetually denied keys. Their rows can therefore remain afterreset_time + 24 hours, allowing stale rows to accumulate in non-Lambda deployments.Create and cancel a context with the
Applicationlifecycle, then pass it toStartCleanupWorkerinstead of the request context. The database function does not provide an alternative scheduled cleanup.🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed the request-context lifetime defect and tracked it in existing issue #269. PR #455 expands its exposure to all runtimes. The missing periodic cleanup is a retention defect; it does not prevent rate-limit windows from resetting.
Independent review of final commit ef58fda traced the SQL upsert: an expired window resets count to 1 and advances reset_time. A fresh PostgreSQL 17 race run of TestDBRateLimiter_WindowExpiry_AtomicReset passed without starting the cleanup worker, including a seeded expired count of 99. Opportunistic cleanup uses its own bounded background context and globally deletes stale rows, rather than only the triggering key.
The application-owned worker lifecycle and shutdown proof remain in #269. The independent reviewer found no rate-budget correctness dependency requiring that separate change in #455. This is a documented deferral of a valid defect, not a claim that scheduled cleanup works. The final shared-budget integration also passed on two real local HTTP servers and PostgreSQL with race and coverage enabled (45.688s); hosted CI passed for the same commit. Review used gpt-6-astra under the user-authorized substitution, not Opus.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@cristim, thanks for the clarification. The cleanup-worker defect affects retention, not rate-limit window resets. The SQL upsert resets expired windows independently of the worker. Opportunistic cleanup also deletes stale rows globally using its own bounded background context.I accept keeping the application-owned worker lifecycle and shutdown tests in
#269, separate from#455. This finding remains valid but deferred. It does not establish a rate-budget correctness blocker for#455.✏️ Learnings added
You are interacting with an AI system.