CODE_CONVENTIONS.md sets out the conventions for Go code in this repository: naming, layout, comments, errors, logging, configuration and tests. .golangci.json enables the linters that check the parts that can be checked (each limited to the rules the conventions use); the rest is applied by hand. Applied across the codebase: - Naming: full-word names in application code, initialisms in one case (BuyerID, PrintJSON, PrintYAML, ocdsGetBuyerID), no names shadowing built-ins (min/max are minGap/maxGap), mustRaw renamed rawJSON as it returns nil rather than panicking, and config.ConfigLevelToLogLevel renamed LevelToLogLevel. - Layout: lines kept to 80 characters where possible, assignments taken out of if and for statements that don't fit, wrapped arguments all on one line or one per line, steps separated by blank lines, no else after a return, function and struct literals wrapped. - Comments: doc comments on every exported name (including each section's Map, Flatten, TypeOf, ValueOf and SetValue), section banners, British spelling. - Errors: a created error starts with its package name, a wrapping error adds only what was being done, sentinels in one commented var block, errors.Is and errors.As in place of == and type assertions, and no err := shadowing of a named result. That fixes the mock server losing its access log's close error. - Logging: the fetcher passes its workers an untagged logger, so findatender's lines are tagged with their own component rather than with fetcher's as well; cmd/mock-fts logs "stopped" with slog. - Configuration: util.WithTimeout (moved from the websocket package) applies a timeout of 0 or less as no limit, used for the websocket write and ping, TCP close and tcpserver shutdown timeouts; every setting documents what 0 or empty means, including the rate limit rule's keys in internal/config/README.md. - Tests: related tests merged into table-driven ones with named cases. Separately from CODE_CONVENTIONS.md, internal/store/README.md gains a soft SQL style convention for the queries in internal/store/queries.go, and the queries are reformatted to follow it.
135 lines
3.7 KiB
Go
135 lines
3.7 KiB
Go
package mock
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ---------- rate limiting ----------
|
|
|
|
// clientWindow is one client's rate limiting state.
|
|
type clientWindow struct {
|
|
hour time.Time // clock hour the anchor belongs to
|
|
anchor time.Time // first request of that hour
|
|
windowStart time.Time // start of the current window
|
|
count int // accepted requests in the current window
|
|
}
|
|
|
|
// RateLimiter limits each client (by remote host) to a number of requests per
|
|
// fixed window, answering the rest with 429 Too Many Requests and a
|
|
// Retry-After header, as the Find a Tender API does. A client's windows are
|
|
// counted from its first request in each clock hour.
|
|
type RateLimiter struct {
|
|
mu sync.Mutex
|
|
limit int
|
|
window time.Duration
|
|
clients map[string]*clientWindow
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewRateLimiter returns a RateLimiter allowing limit requests per window.
|
|
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
|
return &RateLimiter{
|
|
limit: limit,
|
|
window: window,
|
|
clients: map[string]*clientWindow{},
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
// allow records a request for key and reports whether it may proceed. When it
|
|
// may not, it also returns the whole seconds until the client's next window.
|
|
func (rl *RateLimiter) allow(key string) (bool, int) {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
now := rl.now().UTC()
|
|
hour := now.Truncate(time.Hour)
|
|
|
|
client, ok := rl.clients[key]
|
|
if !ok || !client.hour.Equal(hour) {
|
|
// First request of this clock hour: it anchors the windows.
|
|
if !ok && len(rl.clients) > 10000 {
|
|
rl.prune(hour)
|
|
}
|
|
client = &clientWindow{
|
|
hour: hour,
|
|
anchor: now,
|
|
windowStart: now,
|
|
}
|
|
rl.clients[key] = client
|
|
}
|
|
|
|
// Move to whichever fixed window, counted from the anchor, now falls in.
|
|
index := now.Sub(client.anchor) / rl.window
|
|
windowStart := client.anchor.Add(index * rl.window)
|
|
if !windowStart.Equal(client.windowStart) {
|
|
client.windowStart = windowStart
|
|
client.count = 0
|
|
}
|
|
|
|
if client.count < rl.limit {
|
|
client.count++
|
|
return true, 0
|
|
}
|
|
|
|
// The next window opens at the end of this one, or at the top of the
|
|
// next hour if that comes first (because a new hour re-anchors).
|
|
next := client.windowStart.Add(rl.window)
|
|
if nextHour := hour.Add(time.Hour); nextHour.Before(next) {
|
|
next = nextHour
|
|
}
|
|
|
|
seconds := int((next.Sub(now) + time.Second - 1) / time.Second) // round up
|
|
if seconds < 1 {
|
|
seconds = 1
|
|
}
|
|
|
|
return false, seconds
|
|
}
|
|
|
|
// prune drops clients whose state belongs to an earlier hour.
|
|
func (rl *RateLimiter) prune(hour time.Time) {
|
|
for key, client := range rl.clients {
|
|
if client.hour.Before(hour) {
|
|
delete(rl.clients, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// clientKey produces an identifier for the client based on remote address.
|
|
// Could be updated to support proxy and forwarded-for headers.
|
|
func clientKey(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
// Middleware attaches to http handler. Checks to see if a client is allowed
|
|
// to make a request in their current window or returns a "TooManyRequests"
|
|
// status and the time the client should wait before making another request.
|
|
func (rl *RateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ok, retry := rl.allow(clientKey(r))
|
|
if !ok {
|
|
w.Header().Set("Retry-After", strconv.Itoa(retry))
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
fmt.Fprintf( //nolint:errcheck // the client may have gone; nothing to do
|
|
w,
|
|
"Rate limit of %d exceeded. Please retry after %d seconds.",
|
|
rl.limit,
|
|
retry,
|
|
)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|