Skip to content
Draft
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
1 change: 1 addition & 0 deletions APIClient.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//go:generate ./scripts/generate_scopes.sh
//go:generate ./scripts/generate_ratelimit_map.sh

package goesi

Expand Down
42 changes: 37 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,20 +199,52 @@ 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

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

Expand Down
53 changes: 53 additions & 0 deletions examples/ratelimit/main.go
Original file line number Diff line number Diff line change
@@ -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!")
}
90 changes: 90 additions & 0 deletions ratelimit/limiter.go
Original file line number Diff line number Diff line change
@@ -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()
}
106 changes: 106 additions & 0 deletions ratelimit/limiter_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
Loading