diff --git a/APIClient.go b/APIClient.go index 7cd73d3..5f3ce78 100644 --- a/APIClient.go +++ b/APIClient.go @@ -1,4 +1,5 @@ //go:generate ./scripts/generate_scopes.sh +//go:generate ./scripts/generate_ratelimit_map.sh package goesi diff --git a/README.md b/README.md index bf5f4d3..22dbb39 100644 --- a/README.md +++ b/README.md @@ -199,13 +199,44 @@ if err != nil { } ``` -## Rate Limits +## Rate Limiting -ESI rate limits: -- **20 requests/second** for authenticated requests -- **10 requests/second** for public requests +This library includes a client-side rate limiter to respect ESI's floating window rate limits and prevent `429 Too Many Requests` errors. The rate limiter: -The client automatically includes required headers like `X-Compatibility-Date`. +- **Synchronizes** with ESI server state using `X-RateLimit-Remaining` headers +- **Matches** requests to their specific rate limit groups (defined in ESI spec) +- **Waits** automatically if the local bucket is exhausted +- **Respects** authentication context (Character-based limits vs IP-based limits) + +### Usage + +Wrap your `http.Client` transport with `ratelimit.NewTransport`: + +```go +import ( + "net/http" + "github.com/fnt-eve/goesi-openapi" + "github.com/fnt-eve/goesi-openapi/ratelimit" +) + +func main() { + // Create rate limiting transport + rlTransport := ratelimit.NewTransport(http.DefaultTransport) + + // Create HTTP client with the transport + httpClient := &http.Client{ + Transport: rlTransport, + } + + // Initialize ESI client + client := goesi.NewESIClientWithOptions(httpClient, goesi.ClientOptions{ + UserAgent: "MyApp/1.0 (contact@example.com)", + }) + + // Make requests as usual + // The client will now automatically respect rate limits +} +``` ## Examples @@ -213,6 +244,7 @@ See [`examples/`](examples/) directory: - [`basic-oauth2/`](examples/basic-oauth2/main.go) - Complete OAuth2 flow with authenticated client - [`context-auth/`](examples/context-auth/main.go) - Context-based authentication pattern - [`token-persistence/`](examples/token-persistence/main.go) - Token serialization and TokenSource usage +- [`ratelimit/`](examples/ratelimit/main.go) - Using the client-side rate limiter ## Development diff --git a/examples/ratelimit/main.go b/examples/ratelimit/main.go new file mode 100644 index 0000000..7a5c0c0 --- /dev/null +++ b/examples/ratelimit/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + + "github.com/fnt-eve/goesi-openapi" + "github.com/fnt-eve/goesi-openapi/ratelimit" +) + +// This example demonstrates how to use the rate limiter transport +// with the GoESI client to respect ESI's rate limits. +func main() { + userAgent := "MyEVEApp/1.0 (contact@example.com)" + + // Step 1: Create the rate limiting transport + // This wraps the default http.Transport (or any other transport) + rlTransport := ratelimit.NewTransport(http.DefaultTransport) + + // Step 2: Create an http.Client using the rate limiting transport + httpClient := &http.Client{ + Transport: rlTransport, + } + + // Step 3: Create the ESI client using the rate limited http client + client := goesi.NewESIClientWithOptions(httpClient, goesi.ClientOptions{ + UserAgent: userAgent, + }) + + // Step 4: Make requests + // The rate limiter will automatically: + // - Match the request to the correct ESI rate limit bucket + // - Wait if the bucket is empty + // - Update the bucket state based on response headers + + ctx := context.Background() + fmt.Println("Making requests to /status/...") + + // Make a few requests to demonstrate rate limiting (though limits are high for /status) + for i := range 3 { + status, _, err := client.StatusAPI.GetStatus(ctx).Execute() + if err != nil { + log.Printf("Request %d failed: %v", i+1, err) + continue + } + + fmt.Printf("Request %d: Server %s, Players %d\n", i+1, status.ServerVersion, status.Players) + } + + fmt.Println("\nRate limiter example completed successfully!") +} diff --git a/ratelimit/limiter.go b/ratelimit/limiter.go new file mode 100644 index 0000000..6caba0d --- /dev/null +++ b/ratelimit/limiter.go @@ -0,0 +1,90 @@ +package ratelimit + +import ( + "context" + "fmt" + "sync" + "time" +) + +// TokenBucket implements a thread-safe token bucket algorithm +// Inspired by golang.org/x/time/rate +type TokenBucket struct { + mu sync.Mutex + capacity float64 + remaining float64 + rate float64 // tokens per second + lastUpdated time.Time +} + +// NewTokenBucket creates a new token bucket +func NewTokenBucket(limit int, window time.Duration) *TokenBucket { + return &TokenBucket{ + capacity: float64(limit), + remaining: float64(limit), + rate: float64(limit) / window.Seconds(), + lastUpdated: time.Now(), + } +} + +// Wait blocks until enough tokens are available or the context is canceled. +func (b *TokenBucket) Wait(ctx context.Context, cost int) error { + waitDuration, err := b.reserve(ctx, cost) + if err != nil { + return err + } + + if waitDuration > 0 { + select { + case <-time.After(waitDuration): + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +func (b *TokenBucket) reserve(ctx context.Context, cost int) (time.Duration, error) { + b.mu.Lock() + defer b.mu.Unlock() + + now := time.Now() + b.advance(now) + + needed := float64(cost) + b.remaining -= needed + + var waitDuration time.Duration + if b.remaining < 0 { + // Calculate time to refill the deficit + deficit := -b.remaining + waitDuration = time.Duration((deficit / b.rate) * float64(time.Second)) + } + + if deadline, ok := ctx.Deadline(); ok { + if now.Add(waitDuration).After(deadline) { + b.remaining += needed + return 0, fmt.Errorf("rate: wait time exceeds context deadline") + } + } + + return waitDuration, nil +} + +func (b *TokenBucket) advance(now time.Time) { + elapsed := now.Sub(b.lastUpdated).Seconds() + b.remaining += elapsed * b.rate + if b.remaining > b.capacity { + b.remaining = b.capacity + } + b.lastUpdated = now +} + +// Sync updates the bucket state based on server headers +func (b *TokenBucket) Sync(remaining int) { + b.mu.Lock() + defer b.mu.Unlock() + b.remaining = float64(remaining) + b.lastUpdated = time.Now() +} diff --git a/ratelimit/limiter_test.go b/ratelimit/limiter_test.go new file mode 100644 index 0000000..04b3499 --- /dev/null +++ b/ratelimit/limiter_test.go @@ -0,0 +1,106 @@ +package ratelimit + +import ( + "context" + "testing" + "time" +) + +func TestTokenBucket(t *testing.T) { + tests := []struct { + name string + limit int + window time.Duration + initialWait int + secondWait int + timeout time.Duration + expectError bool + minWaitTime time.Duration + }{ + { + name: "Basic Wait", + limit: 10, + window: time.Second, + initialWait: 10, + secondWait: 0, + timeout: time.Second, + expectError: false, + }, + { + name: "Wait with Delay", + limit: 10, + window: time.Second, + initialWait: 10, + secondWait: 1, // Requires waiting + timeout: time.Second, + expectError: false, + minWaitTime: 90 * time.Millisecond, + }, + { + name: "Context Cancel", + limit: 1, + window: time.Minute, + initialWait: 1, + secondWait: 1, // Should wait 1 minute, but timeout is short + timeout: 10 * time.Millisecond, + expectError: true, + }, + { + name: "Zero Cost", + limit: 10, + window: time.Second, + initialWait: 0, + secondWait: 0, + timeout: time.Second, + expectError: false, + }, + { + name: "Exceed Capacity", + limit: 5, + window: time.Second, + initialWait: 0, + secondWait: 10, // Cost > Capacity. Should wait until accumulated. + // Rate = 5/s. Need 10 tokens. + // Start with 5. Remaining = -5. Deficit = 5. + // Wait time = 5 / 5 = 1s. + timeout: 2 * time.Second, + expectError: false, + minWaitTime: 950 * time.Millisecond, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bucket := NewTokenBucket(tt.limit, tt.window) + + ctx := context.Background() + if tt.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, tt.timeout) + defer cancel() + } + + // Initial consume + if err := bucket.Wait(ctx, tt.initialWait); err != nil { + if !tt.expectError { + t.Errorf("Initial Wait() error = %v", err) + } + return + } + + if tt.secondWait > 0 { + start := time.Now() + err := bucket.Wait(ctx, tt.secondWait) + elapsed := time.Since(start) + + if (err != nil) != tt.expectError { + t.Errorf("Wait() error = %v, expectError %v", err, tt.expectError) + } + + if !tt.expectError && elapsed < tt.minWaitTime { + t.Errorf("Wait() took %v, want > %v", elapsed, tt.minWaitTime) + } + } + }) + } +} diff --git a/ratelimit/mapping.go b/ratelimit/mapping.go new file mode 100644 index 0000000..aa1a101 --- /dev/null +++ b/ratelimit/mapping.go @@ -0,0 +1,988 @@ +package ratelimit + +// This file is generated by scripts/generate_ratelimit_map.sh; DO NOT EDIT. + +import ( + "regexp" + "time" +) + +type RouteConfig struct { + Method string + PathRegex *regexp.Regexp + Group string + Limit int + Window time.Duration + Auth bool +} + +var RouteMapping = []RouteConfig{ + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/alliances/[^/]+/contacts/?$`), + Group: "alliance-social", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/alliances/[^/]+/contacts/labels/?$`), + Group: "alliance-social", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/agents_research/?$`), + Group: "char-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/attributes/?$`), + Group: "char-detail", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/blueprints/?$`), + Group: "char-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/calendar/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/calendar/[^/]+/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "PUT", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/calendar/[^/]+/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/calendar/[^/]+/attendees/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/clones/?$`), + Group: "char-location", + Limit: 1200, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "DELETE", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/contacts/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/contacts/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/contacts/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "PUT", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/contacts/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/contacts/labels/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/contracts/?$`), + Group: "char-contract", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/contracts/[^/]+/bids/?$`), + Group: "char-contract", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/contracts/[^/]+/items/?$`), + Group: "char-contract", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/cspa/?$`), + Group: "char-detail", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/fatigue/?$`), + Group: "char-location", + Limit: 1200, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/fittings/?$`), + Group: "fitting", + Limit: 150, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/fittings/?$`), + Group: "fitting", + Limit: 150, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "DELETE", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/fittings/[^/]+/?$`), + Group: "fitting", + Limit: 150, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/fleet/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/fw/stats/?$`), + Group: "factional-warfare", + Limit: 150, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/implants/?$`), + Group: "char-detail", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/industry/jobs/?$`), + Group: "char-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/killmails/recent/?$`), + Group: "char-killmail", + Limit: 30, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/location/?$`), + Group: "char-location", + Limit: 1200, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/loyalty/points/?$`), + Group: "char-wallet", + Limit: 150, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/mail/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/mail/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/mail/labels/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/mail/labels/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "DELETE", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/mail/labels/[^/]+/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/mail/lists/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "DELETE", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/mail/[^/]+/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/mail/[^/]+/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "PUT", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/mail/[^/]+/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/medals/?$`), + Group: "char-detail", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/mining/?$`), + Group: "char-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/notifications/?$`), + Group: "char-notification", + Limit: 15, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/notifications/contacts/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/online/?$`), + Group: "char-location", + Limit: 1200, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/planets/?$`), + Group: "char-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/planets/[^/]+/?$`), + Group: "char-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/portrait/?$`), + Group: "char-detail", + Limit: 600, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/roles/?$`), + Group: "char-detail", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/ship/?$`), + Group: "char-location", + Limit: 1200, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/skillqueue/?$`), + Group: "char-detail", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/skills/?$`), + Group: "char-detail", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/standings/?$`), + Group: "char-social", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/titles/?$`), + Group: "char-detail", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/wallet/?$`), + Group: "char-wallet", + Limit: 150, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/wallet/journal/?$`), + Group: "char-wallet", + Limit: 150, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/characters/[^/]+/wallet/transactions/?$`), + Group: "char-wallet", + Limit: 150, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporation/[^/]+/mining/extractions/?$`), + Group: "corp-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporation/[^/]+/mining/observers/?$`), + Group: "corp-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporation/[^/]+/mining/observers/[^/]+/?$`), + Group: "corp-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/blueprints/?$`), + Group: "corp-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/contacts/?$`), + Group: "corp-social", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/contacts/labels/?$`), + Group: "corp-social", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/contracts/?$`), + Group: "corp-contract", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/contracts/[^/]+/bids/?$`), + Group: "corp-contract", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/contracts/[^/]+/items/?$`), + Group: "corp-contract", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/customs_offices/?$`), + Group: "corp-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/divisions/?$`), + Group: "corp-wallet", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/fw/stats/?$`), + Group: "factional-warfare", + Limit: 150, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/industry/jobs/?$`), + Group: "corp-industry", + Limit: 600, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/killmails/recent/?$`), + Group: "corp-killmail", + Limit: 30, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/medals/?$`), + Group: "corp-detail", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/medals/issued/?$`), + Group: "corp-detail", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/members/?$`), + Group: "corp-member", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/members/limit/?$`), + Group: "corp-member", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/members/titles/?$`), + Group: "corp-member", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/membertracking/?$`), + Group: "corp-member", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/roles/?$`), + Group: "corp-member", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/roles/history/?$`), + Group: "corp-member", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/shareholders/?$`), + Group: "corp-detail", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/standings/?$`), + Group: "corp-member", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/titles/?$`), + Group: "corp-detail", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/wallets/?$`), + Group: "corp-wallet", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/wallets/[^/]+/journal/?$`), + Group: "corp-wallet", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/corporations/[^/]+/wallets/[^/]+/transactions/?$`), + Group: "corp-wallet", + Limit: 300, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "PUT", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/members/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/members/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "DELETE", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/members/[^/]+/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "PUT", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/members/[^/]+/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "DELETE", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/squads/[^/]+/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "PUT", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/squads/[^/]+/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/wings/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/wings/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "DELETE", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/wings/[^/]+/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "PUT", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/wings/[^/]+/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/fleets/[^/]+/wings/[^/]+/squads/?$`), + Group: "fleet", + Limit: 1800, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/fw/leaderboards/?$`), + Group: "factional-warfare", + Limit: 150, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/fw/leaderboards/characters/?$`), + Group: "factional-warfare", + Limit: 150, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/fw/leaderboards/corporations/?$`), + Group: "factional-warfare", + Limit: 150, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/fw/stats/?$`), + Group: "factional-warfare", + Limit: 150, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/fw/systems/?$`), + Group: "factional-warfare", + Limit: 150, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/fw/wars/?$`), + Group: "factional-warfare", + Limit: 150, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/incursions/?$`), + Group: "incursion", + Limit: 150, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/industry/facilities/?$`), + Group: "industry", + Limit: 150, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/industry/systems/?$`), + Group: "industry", + Limit: 150, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/insurance/prices/?$`), + Group: "insurance", + Limit: 150, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/killmails/[^/]+/[^/]+/?$`), + Group: "killmail", + Limit: 3600, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/route/[^/]+/[^/]+/?$`), + Group: "routes", + Limit: 3600, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/sovereignty/campaigns/?$`), + Group: "sovereignty", + Limit: 600, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/sovereignty/map/?$`), + Group: "sovereignty", + Limit: 600, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/sovereignty/structures/?$`), + Group: "sovereignty", + Limit: 600, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/status/?$`), + Group: "status", + Limit: 600, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/ui/autopilot/waypoint/?$`), + Group: "ui", + Limit: 900, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/ui/openwindow/contract/?$`), + Group: "ui", + Limit: 900, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/ui/openwindow/information/?$`), + Group: "ui", + Limit: 900, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/ui/openwindow/marketdetails/?$`), + Group: "ui", + Limit: 900, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "POST", + PathRegex: regexp.MustCompile(`^/ui/openwindow/newmail/?$`), + Group: "ui", + Limit: 900, + Window: 15 * time.Minute, + Auth: true, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/wars/?$`), + Group: "killmail", + Limit: 3600, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/wars/[^/]+/?$`), + Group: "killmail", + Limit: 3600, + Window: 15 * time.Minute, + Auth: false, + }, + { + Method: "GET", + PathRegex: regexp.MustCompile(`^/wars/[^/]+/killmails/?$`), + Group: "killmail", + Limit: 3600, + Window: 15 * time.Minute, + Auth: false, + }, +} diff --git a/ratelimit/store.go b/ratelimit/store.go new file mode 100644 index 0000000..7d87dc0 --- /dev/null +++ b/ratelimit/store.go @@ -0,0 +1,45 @@ +package ratelimit + +import ( + "sync" + "time" +) + +// BucketKey identifies a specific rate limit bucket (e.g., "group:user_id") +type BucketKey string + +// BucketStore manages a collection of token buckets +type BucketStore struct { + mu sync.RWMutex + buckets map[BucketKey]*TokenBucket +} + +// NewBucketStore creates a new store +func NewBucketStore() *BucketStore { + return &BucketStore{ + buckets: make(map[BucketKey]*TokenBucket), + } +} + +// GetBucket retrieves or creates a bucket +func (s *BucketStore) GetBucket(key BucketKey, limit int, window time.Duration) *TokenBucket { + s.mu.RLock() + bucket, exists := s.buckets[key] + s.mu.RUnlock() + + if exists { + return bucket + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Double check + if bucket, exists := s.buckets[key]; exists { + return bucket + } + + bucket = NewTokenBucket(limit, window) + s.buckets[key] = bucket + return bucket +} diff --git a/ratelimit/store_test.go b/ratelimit/store_test.go new file mode 100644 index 0000000..c6d98be --- /dev/null +++ b/ratelimit/store_test.go @@ -0,0 +1,44 @@ +package ratelimit + +import ( + "testing" + "time" +) + +func TestBucketStore(t *testing.T) { + store := NewBucketStore() + key := BucketKey("test") + + b1 := store.GetBucket(key, 10, time.Second) + b2 := store.GetBucket(key, 10, time.Second) + + if b1 != b2 { + t.Errorf("GetBucket returned different instances for same key") + } +} + +func TestBucketStore_Concurrency(t *testing.T) { + store := NewBucketStore() + key := BucketKey("concurrent") + + done := make(chan bool) + for i := 0; i < 10; i++ { + go func() { + store.GetBucket(key, 10, time.Second) + done <- true + }() + } + + for i := 0; i < 10; i++ { + <-done + } + + // Verify we have 1 bucket + store.mu.RLock() + count := len(store.buckets) + store.mu.RUnlock() + + if count != 1 { + t.Errorf("Expected 1 bucket, got %d", count) + } +} diff --git a/ratelimit/transport.go b/ratelimit/transport.go new file mode 100644 index 0000000..6af280f --- /dev/null +++ b/ratelimit/transport.go @@ -0,0 +1,121 @@ +package ratelimit + +import ( + "net/http" + "strconv" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +// Transport implements http.RoundTripper with rate limiting +type Transport struct { + Base http.RoundTripper + Store *BucketStore +} + +// NewTransport creates a new rate limiting transport +func NewTransport(base http.RoundTripper) *Transport { + if base == nil { + base = http.DefaultTransport + } + return &Transport{ + Base: base, + Store: NewBucketStore(), + } +} + +// RoundTrip executes a single HTTP transaction +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + routeConfig := t.matchRoute(req) + + if routeConfig == nil { + return t.Base.RoundTrip(req) + } + + key := t.getBucketKey(req, routeConfig) + bucket := t.Store.GetBucket(key, routeConfig.Limit, routeConfig.Window) + + // Default cost is 2 tokens per request (standard success cost) + if err := bucket.Wait(req.Context(), 2); err != nil { + return nil, err + } + + resp, err := t.Base.RoundTrip(req) + if err != nil { + return nil, err + } + + if remainStr := resp.Header.Get("X-RateLimit-Remaining"); remainStr != "" { + if remain, err := strconv.Atoi(remainStr); err == nil { + bucket.Sync(remain) + } + } + + return resp, nil +} + +func (t *Transport) matchRoute(req *http.Request) *RouteConfig { + path := req.URL.Path + method := req.Method + + for _, rc := range RouteMapping { + if rc.Method == method && rc.PathRegex.MatchString(path) { + return &rc + } + } + return nil +} + +func (t *Transport) getBucketKey(req *http.Request, routeConfig *RouteConfig) BucketKey { + azp, sub := t.extractClaims(req) + + var subject string + if routeConfig.Auth { + // Authenticated route: use azp:sub + if azp != "" && sub != "" { + subject = azp + ":" + sub + } else if sub != "" { + subject = sub + } else { + subject = "public" + } + } else { + // Public route: use public:azp if available, else public + if azp != "" { + subject = "public:" + azp + } else { + subject = "public" + } + } + + return BucketKey(routeConfig.Group + ":" + subject) +} + +func (t *Transport) extractClaims(req *http.Request) (string, string) { + auth := req.Header.Get("Authorization") + if auth == "" { + return "", "" + } + + parts := strings.Fields(auth) + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + return "", "" + } + + tokenString := parts[1] + + // Parse JWT without verification (we just need ID for rate limiting) + token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return "", "" + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok { + sub, _ := claims["sub"].(string) + azp, _ := claims["azp"].(string) + return azp, sub + } + + return "", "" +} diff --git a/ratelimit/transport_test.go b/ratelimit/transport_test.go new file mode 100644 index 0000000..130c9c0 --- /dev/null +++ b/ratelimit/transport_test.go @@ -0,0 +1,282 @@ +package ratelimit + +import ( + "context" + "errors" + "net/http" + "net/url" + "testing" + "time" +) + +// mockRoundTripper implements http.RoundTripper +type mockRoundTripper struct { + resp *http.Response + err error +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + default: + } + return m.resp, m.err +} + +func TestTransport_RoundTrip(t *testing.T) { + // Setup dummy route in mapping for testing + // Example: /status/ (GET, group status, 600, 15m) + // Example: /alliances/{alliance_id}/contacts/ (GET, alliance-social, 300, 15m) + + tests := []struct { + name string + reqMethod string + reqURL string + reqHeader http.Header + respHeader map[string]string + expectedSubject string + expectedGroup string + expectWait bool + expectSync int // -1 if no sync expected + expectError bool + mockErr error + cancelContext bool + setup func(t *Transport) + }{ + { + name: "No Match PassThrough", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/v1/unknown", + reqHeader: make(http.Header), + expectSync: -1, + }, + { + name: "Public Rate Limit", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/status/", + reqHeader: make(http.Header), + expectedSubject: "public", + expectedGroup: "status", + expectSync: 50, + respHeader: map[string]string{"X-RateLimit-Remaining": "50"}, + }, + { + name: "Public Rate Limit with JWT", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/status/", + reqHeader: http.Header{ + "Authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJDaGFyYWN0ZXI6RVZFOjEyMyIsImF6cCI6ImNsaWVudCJ9.signature"}, + }, + expectedSubject: "public:client", + expectedGroup: "status", + expectSync: -1, + }, + { + name: "Auth Rate Limit (Standard JWT)", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/characters/123/location/", + reqHeader: http.Header{ + // {"sub":"123", "azp":"client1"} + "Authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJhenAiOiJjbGllbnQxIn0.signature"}, + }, + expectedSubject: "client1:123", + expectedGroup: "char-location", + expectSync: -1, + }, + { + name: "Auth Rate Limit (ESI Format)", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/characters/123456/location/", + reqHeader: http.Header{ + // {"sub":"CHARACTER:EVE:123456", "azp":"myclient"} + "Authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJDaGFyYWN0ZXI6RVZFOjEyMzQ1NiIsImF6cCI6Im15Y2xpZW50In0.signature"}, + }, + expectedSubject: "myclient:Character:EVE:123456", + expectedGroup: "char-location", + expectSync: -1, + }, + { + name: "Regex Test - Character Fleet", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/characters/123/fleet/", + reqHeader: make(http.Header), + expectedSubject: "public", + expectedGroup: "fleet", + expectSync: -1, + }, + { + name: "Regex Test - Corp Killmails", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/corporations/456/killmails/recent/", + reqHeader: make(http.Header), + expectedSubject: "public", + expectedGroup: "corp-killmail", + expectSync: -1, + }, + { + name: "Wait Error", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/status/", + reqHeader: make(http.Header), + cancelContext: true, + expectWait: false, // Should fail during wait + expectError: true, + setup: func(tr *Transport) { + // Exhaust bucket for status:public to force wait + key := BucketKey("status:public") + // status limit 600, window 15m. + bucket := tr.Store.GetBucket(key, 600, 15*time.Minute) + bucket.Sync(0) // Set remaining to 0 + }, + }, + { + name: "RoundTrip Error", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/status/", + reqHeader: make(http.Header), + mockErr: errors.New("network error"), + expectError: true, + }, + { + name: "Auth Fallback - Sub Only", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/characters/123/location/", + reqHeader: http.Header{ + // {"sub":"123"} + "Authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.sig"}, + }, + expectedSubject: "123", + expectedGroup: "char-location", + expectSync: -1, + }, + { + name: "Auth Malformed - No Bearer", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/characters/123/location/", + reqHeader: http.Header{ + "Authorization": []string{"Basic 123"}, + }, + expectedSubject: "public", + expectedGroup: "char-location", + expectSync: -1, + }, + { + name: "Auth Malformed - Bearer Empty", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/characters/123/location/", + reqHeader: http.Header{ + "Authorization": []string{"Bearer"}, + }, + expectedSubject: "public", + expectedGroup: "char-location", + expectSync: -1, + }, + { + name: "Auth Invalid JWT", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/characters/123/location/", + reqHeader: http.Header{ + "Authorization": []string{"Bearer invalid.token"}, + }, + expectedSubject: "public", + expectedGroup: "char-location", + expectSync: -1, + }, + { + name: "Auth Empty Claims", + reqMethod: "GET", + reqURL: "https://esi.evetech.net/characters/123/location/", + reqHeader: http.Header{ + // {} + "Authorization": []string{"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.sig"}, + }, + expectedSubject: "public", + expectedGroup: "char-location", + expectSync: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := make(http.Header) + if tt.respHeader != nil { + for k, v := range tt.respHeader { + header.Set(k, v) + } + } + + mockResp := &http.Response{ + StatusCode: 200, + Header: header, + } + + base := &mockRoundTripper{resp: mockResp, err: tt.mockErr} + transport := NewTransport(base) + + if tt.setup != nil { + tt.setup(transport) + } + + u, _ := url.Parse(tt.reqURL) + req := &http.Request{ + Method: tt.reqMethod, + URL: u, + Header: tt.reqHeader, + } + + if tt.cancelContext { + ctx, cancel := context.WithCancel(req.Context()) + cancel() // cancel immediately + req = req.WithContext(ctx) + } + + _, err := transport.RoundTrip(req) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("RoundTrip failed: %v", err) + } + + rc := transport.matchRoute(req) + if tt.expectedGroup != "" { + if rc == nil { + t.Errorf("Expected group %s, got no match", tt.expectedGroup) + } else if rc.Group != tt.expectedGroup { + t.Errorf("Expected group %s, got %s", tt.expectedGroup, rc.Group) + } + } + + if tt.expectSync != -1 { + if rc == nil { + t.Errorf("Expected match for sync, got nil") + } else { + key := transport.getBucketKey(req, rc) + bucket := transport.Store.GetBucket(key, rc.Limit, rc.Window) + + bucket.mu.Lock() + rem := bucket.remaining + bucket.mu.Unlock() + + if int(rem) != tt.expectSync { + t.Errorf("Expected remaining %d, got %f", tt.expectSync, rem) + } + } + } + + if tt.expectedSubject != "" && rc != nil { + key := transport.getBucketKey(req, rc) + expectedKey := BucketKey(tt.expectedGroup + ":" + tt.expectedSubject) + if key != expectedKey { + t.Errorf("getBucketKey() = %v, want %v", key, expectedKey) + } + } + }) + } +} diff --git a/scripts/generate_ratelimit_map.sh b/scripts/generate_ratelimit_map.sh new file mode 100755 index 0000000..b01c3af --- /dev/null +++ b/scripts/generate_ratelimit_map.sh @@ -0,0 +1,63 @@ +#!/bin/bash +set -e + +OUTPUT="ratelimit/mapping.go" + +echo "Generating $OUTPUT..." + +cat > $OUTPUT < [^/]+ +# window parsing: assume "15m" -> 15 * time.Minute +jq -r ' +.paths | to_entries[] | +.key as $path | +.value | to_entries[] | +.key as $method | +select(.value."x-rate-limit") | +{ + method: ($method | ascii_upcase), + path: $path, + group: .value."x-rate-limit".group, + limit: .value."x-rate-limit"."max-tokens", + window: .value."x-rate-limit"."window-size", + auth: (if .value.security then (.value.security | any(has("OAuth2"))) else false end) +} | +" { + Method: \"\(.method)\", + PathRegex: regexp.MustCompile(`^\($path | sub("\\{[^}]+\\}"; "[^/]+"; "g"))/?$`), + Group: \"\(.group)\", + Limit: \(.limit), + Window: \(.window | if endswith("m") then "\(. | rtrimstr("m")) * time.Minute" elif endswith("h") then "\(. | rtrimstr("h")) * time.Hour" elif endswith("s") then "\(. | rtrimstr("s")) * time.Second" else "15 * time.Minute" end), + Auth: \(.auth), + }," +' esi-openapi-spec.json >> $OUTPUT + +echo "}" >> $OUTPUT +gofmt -w $OUTPUT + +echo "Done."