Skip to content
Open
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
16 changes: 16 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Deploy to Fly.io

on:
push:
branches: [main]

jobs:
deploy:
runs-on: ubuntu-latest
concurrency: deploy-production
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This deploys no matter what, even if the tests fail etc. Either needs: the CI job or run go test ./... before flyctl deploy

env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
21 changes: 12 additions & 9 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,28 @@ Path from local prototype to deployed public API.

## Deploy blockers

- [ ] Make `godotenv.Load` best-effort in `main.go` so a missing `.env` is not fatal in production
- [ ] Add a multi-stage `Dockerfile` with a distroless final image
- [ ] Select a hosting platform (Cloud Run, Fly.io, Railway, or a VM)
- [x] Make `godotenv.Load` best-effort in `main.go` so a missing `.env` is not fatal in production
- [x] Add a multi-stage `Dockerfile` with a distroless final image
- [x] Select a hosting platform (Cloud Run, Fly.io, Railway, or a VM)
- [ ] Deploy and configure a domain
- [ ] Set fly.io secrets: `flyctl secrets set INFLUXDB_SERVER_URL=... INFLUXDB_AUTH_TOKEN=... INFLUXDB_ORG=...`
- [ ] Run `flyctl volumes create ribbit_api_data --size 1` then `flyctl deploy`

## Production hygiene

- [ ] Replace `http.ListenAndServe` with `http.Server` configured with read, write, and idle timeouts
- [ ] Add graceful shutdown on SIGTERM
- [x] Replace `http.ListenAndServe` with `http.Server` configured with read, write, and idle timeouts
- [x] Add graceful shutdown on SIGTERM
- [ ] Initialize the InfluxDB client once in `main` rather than per request
- [ ] Add a `/healthz` endpoint
- [ ] Add CORS headers if a browser client will call the API
- [x] Add a `/healthz` endpoint
- [x] Add CORS headers if a browser client will call the API
- [x] Update `go.mod` from `go 1.17` to `1.22` or later
- [ ] Refresh dependencies (`go get -u ./... && go mod tidy`)
- [ ] Replace `log.Println` with `log/slog` for structured logging

## Nice to have

- [ ] GitHub Actions workflow running `go test ./...` and `go vet` on pull requests
- [x] GitHub Actions workflow running `go test ./...` and `go vet` on pull requests
- [x] GitHub Actions workflow deploying to fly.io on push to main (requires `FLY_API_TOKEN` secret)
- [ ] Handler-level tests with a mocked database
- [ ] "Deploying" section in the README
- [x] Rate limiting or API keys if abuse becomes a concern (API keys added; rate limiting still TODO)
- [x] Rate limiting or API keys if abuse becomes a concern (API keys added; rate limiting added)
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/time v0.15.0 // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.72.3 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
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-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
Expand Down
62 changes: 62 additions & 0 deletions internal/ratelimit/ratelimit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Package ratelimit provides per-API-key rate limiting middleware.
package ratelimit

import (
"net/http"
"strings"
"sync"

"golang.org/x/time/rate"
)

// Limiter holds per-key token-bucket limiters.
type Limiter struct {
mu sync.Mutex
entries map[string]*rate.Limiter
r rate.Limit
b int
}

// New creates a Limiter allowing r tokens per second with a burst of b.
func New(r rate.Limit, b int) *Limiter {
return &Limiter{
entries: make(map[string]*rate.Limiter),
r: r,
b: b,
}
}

// Middleware returns HTTP middleware that rate-limits by API key.
// Keys are read from "Authorization: Bearer <key>" or "X-API-Key: <key>",
// matching the auth middleware extraction logic. Requests with no key
// are passed through — the auth middleware upstream handles rejection.
func (l *Limiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := extractKey(r)
if key != "" && !l.get(key).Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}

func (l *Limiter) get(key string) *rate.Limiter {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The actual rate limited never removes a client when added, which will be an infinite memory leak. We should eventually remove clients after some time threshold or use a library like https://github.com/didip/tollbooth which handles the removal.

l.mu.Lock()
defer l.mu.Unlock()
lim, ok := l.entries[key]
if !ok {
lim = rate.NewLimiter(l.r, l.b)
l.entries[key] = lim
}
return lim
}

func extractKey(r *http.Request) string {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function appears to be a duplicate of the one in middleware.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably use the authentication layer. The auth should then stash the verified key in r.Context() and let ratelimit read it from there.

if h := r.Header.Get("Authorization"); h != "" {
if rest, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(rest)
}
}
return strings.TrimSpace(r.Header.Get("X-API-Key"))
}
88 changes: 88 additions & 0 deletions internal/ratelimit/ratelimit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package ratelimit

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"
"golang.org/x/time/rate"
)

func okHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
}

func TestRateLimit_AllowsWithinBurst(t *testing.T) {
l := New(rate.Limit(1), 5)
h := l.Middleware(okHandler())

for i := 0; i < 5; i++ {
req := httptest.NewRequest(http.MethodGet, "/data", nil)
req.Header.Set("X-API-Key", "testkey")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "request %d should be allowed", i+1)
}
}

func TestRateLimit_BlocksAfterBurst(t *testing.T) {
l := New(rate.Limit(1), 3)
h := l.Middleware(okHandler())

for i := 0; i < 3; i++ {
req := httptest.NewRequest(http.MethodGet, "/data", nil)
req.Header.Set("X-API-Key", "testkey")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
}

req := httptest.NewRequest(http.MethodGet, "/data", nil)
req.Header.Set("X-API-Key", "testkey")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusTooManyRequests, rec.Code)
}

func TestRateLimit_IndependentPerKey(t *testing.T) {
l := New(rate.Limit(1), 1)
h := l.Middleware(okHandler())

for _, key := range []string{"key-a", "key-b", "key-c"} {
req := httptest.NewRequest(http.MethodGet, "/data", nil)
req.Header.Set("X-API-Key", key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "first request for %s should pass", key)
}
}

func TestRateLimit_BearerToken(t *testing.T) {
l := New(rate.Limit(1), 1)
h := l.Middleware(okHandler())

req := httptest.NewRequest(http.MethodGet, "/data", nil)
req.Header.Set("Authorization", "Bearer mytoken")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)

req2 := httptest.NewRequest(http.MethodGet, "/data", nil)
req2.Header.Set("Authorization", "Bearer mytoken")
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req2)
require.Equal(t, http.StatusTooManyRequests, rec2.Code)
}

func TestRateLimit_NoKey_PassesThrough(t *testing.T) {
l := New(rate.Limit(1), 1)
h := l.Middleware(okHandler())

req := httptest.NewRequest(http.MethodGet, "/data", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
}
63 changes: 56 additions & 7 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
package main

import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"

_ "modernc.org/sqlite"

"github.com/Ribbit-Network/api/internal/auth"
"github.com/Ribbit-Network/api/internal/data"
"github.com/Ribbit-Network/api/internal/ratelimit"
"github.com/joho/godotenv"
"golang.org/x/time/rate"
)

func main() {
Expand All @@ -31,20 +37,45 @@ func runServer() {
if err != nil {
log.Fatal(err)
}

requireKey := auth.Require(store)
// 60 requests/minute per key with a burst of 30.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment here and the burst limit in the code below disagree. The comment says burst 30, but the code below is burst 60.

limiter := ratelimit.New(rate.Every(time.Second), 60)

http.HandleFunc("/", handle)
http.Handle("/data", requireKey(http.HandlerFunc(data.Handle)))
mux := http.NewServeMux()
mux.HandleFunc("/", handleRoot)
mux.HandleFunc("/healthz", handleHealthz)
mux.Handle("/data", requireKey(limiter.Middleware(http.HandlerFunc(data.Handle))))

port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
addr := fmt.Sprintf(":%s", port)

log.Println("API running at http://localhost" + addr)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatal(err)
srv := &http.Server{
Addr: fmt.Sprintf(":%s", port),
Handler: corsMiddleware(mux),
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}

go func() {
log.Println("API running at http://localhost" + srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("shutdown: %v", err)
}
}

Expand All @@ -60,6 +91,24 @@ func openKeyStore() (*auth.Store, error) {
return auth.NewStore(db)
}

func handle(w http.ResponseWriter, _ *http.Request) {
func handleRoot(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprintln(w, "🐸")
}

func handleHealthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintln(w, "ok")
}

func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, X-API-Key, Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
Loading