1 Commits
Author SHA1 Message Date
sneak 99fbbe10f7 Keep counts not written by the request's deadline in memory (closes #224)
check / check (push) Canceled after 0s
TestService_Get_ReturnsByItsDeadline failed on a busy host because its
request, past its deadline, still waited to write its miss count, a write
with no deadline; in pixad that write waits for the one database
connection every request shares. Each count write now keeps the request's
deadline but not its cancellation. A count not written by then is kept in
memory, where Stats includes it, and one goroutine of the cache writes
those counts one UPDATE at a time, and once more at shutdown before the
database closes. The test phase also runs go test with -parallel 4: on a
busy host, as many tests at once as there are CPUs wait so long to be
scheduled that a timed request can fail.

Model: opus-5-5
2026-10-08 14:46:23 +00:00
14 changed files with 118 additions and 616 deletions
+17 -34
View File
@@ -46,9 +46,9 @@ everything in this list without further settings. The reverse proxy must:
builds encrypted URLs from `Host`, and checks `Referer` against builds encrypted URLs from `Host`, and checks `Referer` against
`referer_blocklist`; `referer_blocklist`;
- set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to - set `X-Forwarded-For` to the client's address, with `trusted_proxies` set to
the address pixa sees the proxy's requests come from, so the login limit and the address pixa sees the proxy's requests come from, so the login limit
`image_requests_per_minute` count each client by its own address (see counts each client by its own address (see `trusted_proxies` under
`trusted_proxies` under Configuration); Configuration);
- wait for pixa's answer for at least `downstream_timeout` (default `60s`), the - wait for pixa's answer for at least `downstream_timeout` (default `60s`), the
longest pixa takes to fetch, convert and send an image. longest pixa takes to fetch, convert and send an image.
@@ -58,9 +58,8 @@ password there.
pixa does the rest itself: it checks signatures and encrypted URLs, applies the pixa does the rest itself: it checks signatures and encrypted URLs, applies the
allowlist, refuses upstream hosts with private or local addresses, limits login allowlist, refuses upstream hosts with private or local addresses, limits login
attempts, each client's image requests, upstream response size and image attempts, upstream response size and image dimensions, and sends the security
dimensions, and sends the security headers, `Strict-Transport-Security` headers, `Strict-Transport-Security` included, with every response.
included, with every response.
The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the The state directory (`state_dir`, `/var/lib/pixa` in the container) holds the
database and the disk cache: database and the disk cache:
@@ -189,10 +188,10 @@ path under `/v1/` answers 200, in maintenance mode too.
`Referer` names a host in `referer_blocklist`, checked before the signature, `Referer` names a host in `referer_blocklist`, checked before the signature,
the cache and the upstream fetch; 403 when the upstream host, or a host it the cache and the upstream fetch; 403 when the upstream host, or a host it
redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an
address in a blocked network (see `blocked_networks`); 429 over address in a blocked network (see `blocked_networks`); 502 when the upstream
`image_requests_per_minute` (below); 502 when the upstream answered with an answered with an error status, and for 5 minutes after that for the same
error status, and for 5 minutes after that for the same source URL; 503 when source URL; 503 when pixa is busy or in maintenance mode; 500 for any other
pixa is busy or in maintenance mode; 500 for any other failure. failure.
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL - `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when (see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
`If-None-Match` matches the image's `ETag`; 400 for a token that does not `If-None-Match` matches the image's `ETag`; 400 for a token that does not
@@ -200,7 +199,7 @@ path under `/v1/` answers 200, in maintenance mode too.
expired; 504 when the upstream has not sent its response headers within expired; 504 when the upstream has not sent its response headers within
`upstream_fetch_timeout`, but 500 when that time runs out while the image `upstream_fetch_timeout`, but 500 when that time runs out while the image
itself is still arriving; 400 for an `Accept` header that is not valid, and itself is still arriving; 400 for an `Accept` header that is not valid, and
406, 403, 429, 502, 503 and 500, as for `/v1/image/`. 406, 403, 502, 503 and 500, as for `/v1/image/`.
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs: - `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
nothing. Answers: 200. nothing. Answers: 200.
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`, - `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
@@ -316,15 +315,6 @@ its own `X-Forwarded-For`, whether it connects directly or through the proxy,
because its own address is trusted too. Setting `trusted_proxies` to only the because its own address is trusted too. Setting `trusted_proxies` to only the
address pixa sees for requests that come through the proxy closes this. address pixa sees for requests that come through the proxy closes this.
One client may send `image_requests_per_minute` requests per minute
(default 600) to the image routes, `/v1/image/` and `/v1/e/` together, `GET` and
`HEAD`, cache hits included; a request over the limit is refused with 429 and a
`Retry-After` header. Clients are counted as for the login limit. A browser's
CORS preflight is not counted. Behind a CDN or proxy whose addresses are not in
`trusted_proxies`, all requests arriving from one proxy or CDN address count as
one client, so such a deployment needs a higher limit, or `0`, which turns the
limit off.
### Encrypted URLs ### Encrypted URLs
An encrypted URL is an image URL made on pixa's own web page by someone who An encrypted URL is an image URL made on pixa's own web page by someone who
@@ -494,7 +484,6 @@ tell. With no file, pixa uses the environment and the defaults.
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` | | `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
| `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` | | `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` |
| `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default the number of CPUs | | `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default the number of CPUs |
| `PIXA_IMAGE_REQUESTS_PER_MINUTE` | `image_requests_per_minute` | Image requests per client per minute; `0` turns it off; default `600` |
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` | | `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB | | `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` | | `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
@@ -544,13 +533,13 @@ Key settings in more detail:
explicit list replaces the default. An invalid CIDR aborts startup. Set this explicit list replaces the default. An invalid CIDR aborts startup. Set this
to the address pixa sees for requests that come through your proxy, such as to the address pixa sees for requests that come through your proxy, such as
`172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else `172.17.0.1/32`, when the defaults do not cover it, or to trust nothing else
(see the login limit and `image_requests_per_minute` under Routes). For a (see the login limit under Routes). For a proxy on the Docker host that
proxy on the Docker host that connects to pixa over `127.0.0.1`, that address connects to pixa over `127.0.0.1`, that address is the gateway of the
is the gateway of the container's Docker network (`172.17.0.1` on the default container's Docker network (`172.17.0.1` on the default bridge), not the
bridge), not the proxy's own address; a proxy that connects through another of proxy's own address; a proxy that connects through another of the host's
the host's addresses is seen with that address. To be sure which address it addresses is seen with that address. To be sure which address it is, set this
is, set this to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request to `[]` (or `PIXA_TRUSTED_PROXIES` to empty), send a request through the
through the proxy, and read `remoteIP` in pixa's request log line for it proxy, and read `remoteIP` in pixa's request log line for it
- `upstream_fetch_timeout` — time allowed for one fetch from an upstream host, - `upstream_fetch_timeout` — time allowed for one fetch from an upstream host,
as a duration such as `30s` (the default) or `2m` as a duration such as `30s` (the default) or `2m`
- `upstream_max_response_size` — largest upstream response accepted, in bytes; - `upstream_max_response_size` — largest upstream response accepted, in bytes;
@@ -583,12 +572,6 @@ Key settings in more detail:
container's CPU limit. A request that finds all of them in use waits up to 10 container's CPU limit. A request that finds all of them in use waits up to 10
seconds for one to free up; if none does, and `downstream_timeout` has not seconds for one to free up; if none does, and `downstream_timeout` has not
ended first, it is answered 503 the same way ended first, it is answered 503 the same way
- `image_requests_per_minute` — how many requests one client may send to the
image routes per minute (see the limit on them under Routes); default `600`.
`0` turns the limit off, and a negative or non-integer value aborts startup.
Behind a CDN or proxy whose addresses are not in `trusted_proxies`, all
requests arriving from one proxy or CDN address count as one client, so raise
the limit there, or set `0`
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and - `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
`/v1/e/`) answer every request for an image with 503, a `Retry-After` header `/v1/e/`) answer every request for an image with 503, a `Retry-After` header
and a JSON error body. The health check (`/.well-known/healthcheck.json`) and a JSON error body. The health check (`/.well-known/healthcheck.json`)
+3 -9
View File
@@ -26,18 +26,10 @@ resolved. The disk cache is now size-bounded with LRU eviction
# Next Step # Next Step
P2: security: per-origin rate limiting P2: security: per-IP rate limiting on the image routes
# Completed Steps # Completed Steps
- 2026-10-12 per-client limit on the image routes (closes #236): the new setting
`image_requests_per_minute` (`PIXA_IMAGE_REQUESTS_PER_MINUTE`), default 600,
is how many `GET` and `HEAD` requests one client may send to `/v1/image/` and
`/v1/e/` together per minute, cache hits included; the next is answered 429
with `Retry-After`. It uses the login limit's `RateLimit` middleware, so
clients are told apart the same way, and runs after the CORS middleware, so a
preflight is not counted. `0` turns the limit off; a negative or non-integer
value aborts startup.
- 2026-10-08 a request past its deadline no longer waits for the database to - 2026-10-08 a request past its deadline no longer waits for the database to
count it (closes #224). On a busy host, `TestService_Get_ReturnsByItsDeadline` count it (closes #224). On a busy host, `TestService_Get_ReturnsByItsDeadline`
failed because its request, past its deadline, still waited for its miss count failed because its request, past its deadline, still waited for its miss count
@@ -757,6 +749,8 @@ P2: security: per-origin rate limiting
# Future Steps # Future Steps
- P2: security
- per-origin rate limiting
- P2: HTTP response handling - P2: HTTP response handling
- Last-Modified headers - Last-Modified headers
- P2: configuration - P2: configuration
-8
View File
@@ -115,14 +115,6 @@ upstream_connections: 64
# frees up it is answered 503, unless downstream_timeout has ended first. # frees up it is answered 503, unless downstream_timeout has ended first.
# max_concurrent_processing: 4 # max_concurrent_processing: 4
# How many requests to the image routes (/v1/image/ and /v1/e/) one client
# may send per minute; a request over the limit is answered 429 with a
# Retry-After header. 0 turns the limit off. Clients are told apart as for
# the login limit, so behind a CDN or proxy whose addresses are not in
# trusted_proxies all requests arriving from one proxy or CDN address count
# as one client: raise the limit there, or set 0. (default: 600)
image_requests_per_minute: 600
# Time allowed for one fetch from an upstream host (default: 30s) # Time allowed for one fetch from an upstream host (default: 30s)
upstream_fetch_timeout: 30s upstream_fetch_timeout: 30s
+10 -39
View File
@@ -34,7 +34,6 @@ const (
DefaultUpstreamFetchTimeout = 30 * time.Second DefaultUpstreamFetchTimeout = 30 * time.Second
DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB DefaultUpstreamMaxResponseSize = 50 << 20 // 50 MiB
DefaultDownstreamTimeout = 60 * time.Second DefaultDownstreamTimeout = 60 * time.Second
DefaultImageRequestsPerMinute = 600
) )
// Configuration key names. // Configuration key names.
@@ -62,7 +61,6 @@ const (
keyUpstreamFetchTimeout = "upstream_fetch_timeout" keyUpstreamFetchTimeout = "upstream_fetch_timeout"
keyUpstreamMaxResponseSize = "upstream_max_response_size" keyUpstreamMaxResponseSize = "upstream_max_response_size"
keyDownstreamTimeout = "downstream_timeout" keyDownstreamTimeout = "downstream_timeout"
keyImageRequestsPerMinute = "image_requests_per_minute"
) )
// placeholderSigningKey is the dummy signing_key shipped in // placeholderSigningKey is the dummy signing_key shipped in
@@ -160,11 +158,6 @@ type Config struct {
// of the per-request timeout middleware. // of the per-request timeout middleware.
DownstreamTimeout time.Duration DownstreamTimeout time.Duration
// ImageRequestsPerMinute is how many requests one client may make to
// the image routes per minute; the next is refused with 429 Too Many
// Requests. 0 turns the limit off.
ImageRequestsPerMinute int
// BlockedNetworks are operator-supplied CIDR ranges to refuse in // BlockedNetworks are operator-supplied CIDR ranges to refuse in
// addition to the built-in SSRF blocklist. Enforced by the upstream // addition to the built-in SSRF blocklist. Enforced by the upstream
// fetcher's dialer; the built-in ranges always apply. // fetcher's dialer; the built-in ranges always apply.
@@ -309,8 +302,6 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin), keyAccessControlAllowOrigin, DefaultAccessControlAllowOrigin),
DownstreamTimeout: loader.durationVal( DownstreamTimeout: loader.durationVal(
keyDownstreamTimeout, DefaultDownstreamTimeout), keyDownstreamTimeout, DefaultDownstreamTimeout),
ImageRequestsPerMinute: loader.intVal(
keyImageRequestsPerMinute, DefaultImageRequestsPerMinute),
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0), CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
BlockedNetworks: blockedNetworks, BlockedNetworks: blockedNetworks,
TrustedProxies: trustedProxies, TrustedProxies: trustedProxies,
@@ -320,7 +311,9 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
// The default for an omitted cache_max_bytes is worked out when // The default for an omitted cache_max_bytes is worked out when
// the cache opens; here we only record whether the operator set // the cache opens; here we only record whether the operator set
// the key explicitly. // the key explicitly.
_, c.CacheMaxBytesExplicit = lookupValue(sc, keyCacheMaxBytes) if _, present := lookupValue(sc, keyCacheMaxBytes); present {
c.CacheMaxBytesExplicit = true
}
// Build DBURL from StateDir if not explicitly set. The derived URL // Build DBURL from StateDir if not explicitly set. The derived URL
// is a default: it applies only when db_url is omitted, never to an // is a default: it applies only when db_url is omitted, never to an
@@ -433,7 +426,7 @@ func isKnownConfigKey(key string) bool {
keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks, keyMaxConcurrentProcessing, keyCacheMaxBytes, keyBlockedNetworks,
keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout, keyTrustedProxies, keyAccessControlAllowOrigin, keyUpstreamFetchTimeout,
keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist, keyUpstreamMaxResponseSize, keyDownstreamTimeout, keyRefererBlocklist,
keyImageRequestsPerMinute, "env": "env":
return true return true
} }
@@ -468,7 +461,6 @@ func envVarNames() map[string]string {
keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT", keyUpstreamFetchTimeout: "PIXA_UPSTREAM_FETCH_TIMEOUT",
keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE", keyUpstreamMaxResponseSize: "PIXA_UPSTREAM_MAX_RESPONSE_SIZE",
keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT", keyDownstreamTimeout: "PIXA_DOWNSTREAM_TIMEOUT",
keyImageRequestsPerMinute: "PIXA_IMAGE_REQUESTS_PER_MINUTE",
} }
} }
@@ -621,14 +613,6 @@ func (c *Config) validate() error {
settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative) settingName(keyCacheMaxBytes), c.CacheMaxBytes, errMustNotBeNegative)
} }
// Zero is valid (it turns the limit off); only negative values are
// rejected.
if c.ImageRequestsPerMinute < 0 {
return fmt.Errorf("%s: value %d %w",
settingName(keyImageRequestsPerMinute), c.ImageRequestsPerMinute,
errMustNotBeNegative)
}
err = c.validateUpstreamMaxResponseSize() err = c.validateUpstreamMaxResponseSize()
if err != nil { if err != nil {
return err return err
@@ -641,9 +625,12 @@ func (c *Config) validate() error {
} }
} }
err = c.validateSentryDSN() if c.SentryDSN != "" {
if err != nil { parsed, err := url.Parse(c.SentryDSN)
return err if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("%s: value %q is %w",
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
}
} }
if (c.MetricsUsername == "") != (c.MetricsPassword == "") { if (c.MetricsUsername == "") != (c.MetricsPassword == "") {
@@ -655,22 +642,6 @@ func (c *Config) validate() error {
return c.validateAccessControlAllowOrigin() return c.validateAccessControlAllowOrigin()
} }
// validateSentryDSN checks that sentry_dsn, when set, is a URL with a scheme
// and a host.
func (c *Config) validateSentryDSN() error {
if c.SentryDSN == "" {
return nil
}
parsed, err := url.Parse(c.SentryDSN)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("%s: value %q is %w",
settingName(keySentryDSN), c.SentryDSN, errNotAValidURL)
}
return nil
}
// validateUpstreamMaxResponseSize checks that upstream_max_response_size // validateUpstreamMaxResponseSize checks that upstream_max_response_size
// is from 1 byte to 1 GiB. An upstream response is read whole into // is from 1 byte to 1 GiB. An upstream response is read whole into
// memory, and the image processor reads one byte past this limit, which // memory, and the image processor reads one byte past this limit, which
-2
View File
@@ -70,7 +70,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5") t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5")
t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10") t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10")
t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3") t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3")
t.Setenv("PIXA_IMAGE_REQUESTS_PER_MINUTE", "30")
t.Setenv("PIXA_CACHE_MAX_BYTES", "1024") t.Setenv("PIXA_CACHE_MAX_BYTES", "1024")
t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24") t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24")
t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24") t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24")
@@ -100,7 +99,6 @@ func TestEnvironmentSetsEveryKey(t *testing.T) {
UpstreamConnectionsPerHost: 5, UpstreamConnectionsPerHost: 5,
UpstreamConnections: 10, UpstreamConnections: 10,
MaxConcurrentProcessing: 3, MaxConcurrentProcessing: 3,
ImageRequestsPerMinute: 30,
CacheMaxBytes: 1024, CacheMaxBytes: 1024,
CacheMaxBytesExplicit: true, CacheMaxBytesExplicit: true,
BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")},
@@ -1,107 +0,0 @@
package config
import "testing"
// testImageRequestsPerMinuteVar is the variable that sets
// image_requests_per_minute.
const testImageRequestsPerMinuteVar = "PIXA_IMAGE_REQUESTS_PER_MINUTE"
// TestImageRequestsPerMinuteValues checks that an omitted
// image_requests_per_minute is 600, and that 0, which turns the limit off,
// and any larger whole number are used as given.
func TestImageRequestsPerMinuteValues(t *testing.T) {
t.Parallel()
c, err := configFromYAML(t, signingKeyLine)
if err != nil {
t.Fatalf("minimal config should be valid, got error: %v", err)
}
if c.ImageRequestsPerMinute != 600 {
t.Errorf("ImageRequestsPerMinute = %d, want 600", c.ImageRequestsPerMinute)
}
explicit := []struct {
value string
want int
}{
{"0", 0},
{"30", 30},
}
for _, tc := range explicit {
c, err := configFromYAML(t,
signingKeyLine+"image_requests_per_minute: "+tc.value+"\n")
if err != nil {
t.Fatalf("image_requests_per_minute: %s should load, got error: %v",
tc.value, err)
}
if c.ImageRequestsPerMinute != tc.want {
t.Errorf("ImageRequestsPerMinute = %d, want %d",
c.ImageRequestsPerMinute, tc.want)
}
}
}
// TestInvalidImageRequestsPerMinuteAbortsStartup checks that a value that is
// negative, not a whole number, or null aborts startup naming the key and
// the value, and the variable too where the value could have come from it.
func TestInvalidImageRequestsPerMinuteAbortsStartup(t *testing.T) {
t.Parallel()
key := keyImageRequestsPerMinute
runAbortCases(t, []abortCase{
{
name: "negative",
yaml: signingKeyLine + key + ": -1\n",
wantErrSubstrings: []string{
key, testImageRequestsPerMinuteVar, "value -1",
},
},
{
name: "not a number",
yaml: signingKeyLine + key + ": plenty\n",
wantErrSubstrings: []string{
key, testImageRequestsPerMinuteVar, "plenty",
},
},
{
name: "fractional",
yaml: signingKeyLine + key + ": 2.5\n",
wantErrSubstrings: []string{key, "2.5"},
},
{
name: "explicit null",
yaml: signingKeyLine + key + ": null\n",
wantErrSubstrings: []string{key, nullValueText},
},
})
}
// TestImageRequestsPerMinuteFromEnvironment checks that the variable sets the
// limit over the config file, and that an invalid value in it aborts startup
// naming the variable and the value.
func TestImageRequestsPerMinuteFromEnvironment(t *testing.T) {
t.Setenv(testImageRequestsPerMinuteVar, "0")
c, err := configFromYAML(t, signingKeyLine+"image_requests_per_minute: 30\n")
if err != nil {
t.Fatalf("limit from the environment should load: %v", err)
}
if c.ImageRequestsPerMinute != 0 {
t.Errorf("ImageRequestsPerMinute = %d, want 0 from the environment",
c.ImageRequestsPerMinute)
}
for _, value := range []string{"-5", "ten"} {
t.Run(value, func(t *testing.T) {
t.Setenv(testImageRequestsPerMinuteVar, value)
_, err := configFromYAML(t, signingKeyLine)
wantStartupError(t, err, testImageRequestsPerMinuteVar, value)
})
}
}
-10
View File
@@ -206,16 +206,6 @@ func parseExpires(query url.Values) (time.Time, error) {
return time.Unix(exp, 0), nil return time.Unix(exp, 0), nil
} }
// HandleTooManyRequests answers 429 Too Many Requests with the JSON error
// body the image routes send for their other errors. The image routes' rate
// limit calls it for a request over the limit.
func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
s.respondError(w, "too many requests, try again later",
http.StatusTooManyRequests)
}
}
// respondImageError maps image retrieval errors to HTTP responses. // respondImageError maps image retrieval errors to HTTP responses.
func (s *Handlers) respondImageError( func (s *Handlers) respondImageError(
w http.ResponseWriter, req *imgcache.ImageRequest, err error, w http.ResponseWriter, req *imgcache.ImageRequest, err error,
+11 -27
View File
@@ -11,7 +11,6 @@ import (
"io" "io"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -87,8 +86,7 @@ type Cache struct {
// StartPendingCountWrites starts. As for eviction, the channels are // StartPendingCountWrites starts. As for eviction, the channels are
// created in NewCache: pendingCountsAdded wakes that goroutine, and // created in NewCache: pendingCountsAdded wakes that goroutine, and
// pendingCountsDone is closed when it returns. pendingCountsCancel, // pendingCountsDone is closed when it returns. pendingCountsCancel,
// set by StartPendingCountWrites, cancels its context, which tells it // set by StartPendingCountWrites, cancels its context.
// to finish.
pendingHits atomic.Int64 pendingHits atomic.Int64
pendingMisses atomic.Int64 pendingMisses atomic.Int64
pendingUpstreamFetches atomic.Int64 pendingUpstreamFetches atomic.Int64
@@ -98,10 +96,6 @@ type Cache struct {
pendingCountsDone chan struct{} pendingCountsDone chan struct{}
pendingCountsCancel context.CancelFunc pendingCountsCancel context.CancelFunc
// Held through each write of the pending counts, and by
// readHitAndMissCounts to read them
pendingCountsWriteMutex sync.Mutex
// metaCache holds the content types of the variants most recently // metaCache holds the content types of the variants most recently
// stored or served, so a hit does not read the variant's .meta file. // stored or served, so a hit does not read the variant's .meta file.
// It never stands in for the variant file, which is always opened. // It never stands in for the variant file, which is always opened.
@@ -500,11 +494,20 @@ func (c *Cache) CleanExpired(ctx context.Context) error {
func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) { func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
var stats CacheStats var stats CacheStats
err := c.readHitAndMissCounts(ctx, &stats) // Fetch hit/miss counts from the stats table
err := c.db.QueryRowContext(ctx, `
SELECT hit_count, miss_count
FROM cache_stats WHERE id = 1
`).Scan(&stats.HitCount, &stats.MissCount)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("failed to get cache stats: %w", err) return nil, fmt.Errorf("failed to get cache stats: %w", err)
} }
// Hits and misses not yet written count too (see StartPendingCountWrites)
stats.HitCount += c.pendingHits.Load()
stats.MissCount += c.pendingMisses.Load()
// Count and size the cached source images and processed variants from // Count and size the cached source images and processed variants from
// their tables. A disabled cache holds none, whatever rows an earlier // their tables. A disabled cache holds none, whatever rows an earlier
// run left. // run left.
@@ -621,25 +624,6 @@ func (c *Cache) IncrementTransformCount(ctx context.Context) {
} }
} }
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
// cache_stats row plus the pending hits and misses (see
// StartPendingCountWrites). It holds pendingCountsWriteMutex, so that no
// write of the pending counts falls between the two reads.
func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) error {
c.pendingCountsWriteMutex.Lock()
defer c.pendingCountsWriteMutex.Unlock()
err := c.db.QueryRowContext(ctx, `
SELECT hit_count, miss_count
FROM cache_stats WHERE id = 1
`).Scan(&stats.HitCount, &stats.MissCount)
stats.HitCount += c.pendingHits.Load()
stats.MissCount += c.pendingMisses.Load()
return err
}
// loadVariantWithMeta is GetVariant for a variant metaCache does not // loadVariantWithMeta is GetVariant for a variant metaCache does not
// hold: it reads the content type from the variant's .meta file and // hold: it reads the content type from the variant's .meta file and
// keeps it in metaCache, unless a StoreVariant has put one there // keeps it in metaCache, unless a StoreVariant has put one there
@@ -8,25 +8,16 @@ import (
"time" "time"
) )
// pendingCountsWait is how long a test waits for the database's connection, // pendingCountsWait is how long a test waits for a count to reach the
// for a count to reach the database, for a write to start waiting for the // database, or for a write to start waiting for the database's connection.
// connection, for a request to return past its deadline, or for
// StopPendingCountWrites to return.
const pendingCountsWait = 5 * time.Second const pendingCountsWait = 5 * time.Second
// otherWritesWait is how long a test gives other writes, once one write waits
// for the database's connection, to start waiting for it too.
const otherWritesWait = 100 * time.Millisecond
// holdDatabase takes the one connection of cache's database, so that every // holdDatabase takes the one connection of cache's database, so that every
// other query waits for it, and returns the func that frees it. // other query waits for it, and returns the func that frees it.
func holdDatabase(t *testing.T, cache *Cache) func() { func holdDatabase(t *testing.T, cache *Cache) func() {
t.Helper() t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), pendingCountsWait) conn, err := cache.db.Conn(t.Context())
defer cancel()
conn, err := cache.db.Conn(ctx)
if err != nil { if err != nil {
t.Fatalf("failed to take the database connection: %v", err) t.Fatalf("failed to take the database connection: %v", err)
} }
@@ -73,9 +64,9 @@ func waitForCounters(t *testing.T, cache *Cache, want cacheStatsCounters) {
// TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the // TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the
// database's one connection while a request whose fetch is held reaches its // database's one connection while a request whose fetch is held reaches its
// deadline. The request must return with the deadline's error while the // deadline. The request must still return by its deadline with the
// connection is still held, as it waits for no database write past its // deadline's error, and its miss must reach the database once the
// deadline, and its miss must reach the database once the connection is free. // connection is free.
func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) { func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
t.Parallel() t.Parallel()
@@ -84,36 +75,30 @@ func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
svc.cache.StartPendingCountWrites() svc.cache.StartPendingCountWrites()
defer func() { _ = svc.cache.StopPendingCountWrites(t.Context()) }() defer func() { _ = svc.cache.StopPendingCountWrites(t.Context()) }()
// Room for the request to reach its fetch on a busy host const timeout = 200 * time.Millisecond
const timeout = 2 * time.Second
ctx, cancel := context.WithTimeout(t.Context(), timeout) ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel() defer cancel()
deadline, _ := ctx.Deadline()
results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover)) results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover))
// The request has made its database reads by the time it fetches. // The request has made its database reads by the time it fetches.
select { <-fetcher.started
case <-fetcher.started:
case <-time.After(timeout):
t.Fatal("request did not reach its fetch by its deadline")
}
releaseDatabase := holdDatabase(t, svc.cache) releaseDatabase := holdDatabase(t, svc.cache)
defer releaseDatabase() defer releaseDatabase()
select { select {
case got := <-results: case got := <-results:
deadline, _ := ctx.Deadline()
t.Logf("Get() returned %v after its deadline, error = %v", t.Logf("Get() returned %v after its deadline, error = %v",
time.Since(deadline), got.err) time.Since(deadline), got.err)
if !errors.Is(got.err, context.DeadlineExceeded) { if !errors.Is(got.err, context.DeadlineExceeded) {
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded) t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
} }
case <-time.After(time.Until(deadline) + pendingCountsWait): case <-time.After(timeout + time.Second):
t.Fatal("request did not return while the database was busy") t.Fatal("request did not return by its deadline while the database was busy")
} }
releaseDatabase() releaseDatabase()
@@ -121,47 +106,67 @@ func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
waitForCounters(t, svc.cache, cacheStatsCounters{missCount: 1}) waitForCounters(t, svc.cache, cacheStatsCounters{missCount: 1})
} }
// TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting holds the // TestService_Get_ManyRequestsPastTheirDeadlineLeaveOneWriteWaiting holds the
// database's one connection while many misses are counted past their // database's one connection while many requests whose fetches are held reach
// deadline. At most one write may then wait for the connection, and every // their deadline. At most one write may then wait for the connection, and
// miss must reach the database once it is free. // every miss must reach the database once it is free.
func TestIncrementStats_ManyCountsPastTheirDeadlineLeaveOneWriteWaiting(t *testing.T) { func TestService_Get_ManyRequestsPastTheirDeadlineLeaveOneWriteWaiting(t *testing.T) {
t.Parallel() t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30) svc, fixtures, fetcher := setupHeldFetchService(t)
cache.StartPendingCountWrites() svc.cache.StartPendingCountWrites()
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }() defer func() { _ = svc.cache.StopPendingCountWrites(t.Context()) }()
releaseDatabase := holdDatabase(t, cache) const (
defer releaseDatabase() requests = 20
timeout = 200 * time.Millisecond
)
waitsBefore := cache.db.Stats().WaitCount ctx, cancel := context.WithTimeout(t.Context(), timeout)
// Writes given a context past its deadline fail at once.
ended, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel() defer cancel()
const misses = 20 // Each request asks for a variant of its own, so each fetches.
results := make([]<-chan getResult, requests)
for range misses { for i := range results {
cache.IncrementStats(ended, false, 0) results[i] = startGet(ctx, svc, photoVariant(fixtures, i+1, FitCover))
} }
// Once one write waits for the connection, any others start waiting too. // Each request has made its database reads by the time it fetches.
waitForConnectionWaits(t, cache.db, waitsBefore+1) for range requests {
time.Sleep(otherWritesWait) <-fetcher.started
}
waits := cache.db.Stats().WaitCount - waitsBefore releaseDatabase := holdDatabase(t, svc.cache)
defer releaseDatabase()
waitsBefore := svc.cache.db.Stats().WaitCount
// Once one write waits for the connection, any others start waiting too.
waitForConnectionWaits(t, svc.cache.db, waitsBefore+1)
time.Sleep(arrivalWait)
waits := svc.cache.db.Stats().WaitCount - waitsBefore
t.Logf("%d writes waited for the database", waits) t.Logf("%d writes waited for the database", waits)
if waits > 1 { if waits > 1 {
t.Errorf("%d writes waited for the database, want at most 1", waits) t.Errorf("%d writes waited for the database, want at most 1", waits)
} }
for _, result := range results {
select {
case got := <-result:
if !errors.Is(got.err, context.DeadlineExceeded) {
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
}
case <-time.After(time.Second):
t.Fatal("request did not return by its deadline while the database was busy")
}
}
releaseDatabase() releaseDatabase()
waitForCounters(t, cache, cacheStatsCounters{missCount: misses}) waitForCounters(t, svc.cache, cacheStatsCounters{missCount: requests})
} }
// TestStats_IncludesPendingCounts counts hits and misses whose writes miss // TestStats_IncludesPendingCounts counts hits and misses whose writes miss
@@ -204,9 +209,8 @@ func TestStats_IncludesPendingCounts(t *testing.T) {
// TestStopPendingCountWrites_WritesPendingCounts holds the database's one // TestStopPendingCountWrites_WritesPendingCounts holds the database's one
// connection while the goroutine that writes the pending counts waits for it, // connection while the goroutine that writes the pending counts waits for it,
// counts more, then stops that goroutine. The stop must leave the write under // then stops that goroutine. The stop must write the counts once the
// way to finish rather than cut it off, and once the connection is free it // connection is free.
// must write the counts that write left.
func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) { func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
t.Parallel() t.Parallel()
@@ -225,20 +229,12 @@ func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
defer cancel() defer cancel()
cache.IncrementStats(ended, true, 0) cache.IncrementStats(ended, true, 0)
// The goroutine's write waits for the connection.
waitForConnectionWaits(t, cache.db, waitsBefore+1)
// Counted after that write read the pending counts
cache.IncrementStats(ended, false, 0) cache.IncrementStats(ended, false, 0)
cache.IncrementUpstreamFetch(ended, 1024) cache.IncrementUpstreamFetch(ended, 1024)
cache.IncrementTransformCount(ended) cache.IncrementTransformCount(ended)
// The goroutine is told to finish here as well as by the stop, so that it // The goroutine's write waits for the connection.
// is told before its write under way ends, however late the stop runs. waitForConnectionWaits(t, cache.db, waitsBefore+1)
// The counts above then reach the database only through the stop's own
// write.
cache.pendingCountsCancel()
stopped := make(chan error, 1) stopped := make(chan error, 1)
@@ -246,17 +242,8 @@ func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
stopped <- cache.StopPendingCountWrites(t.Context()) stopped <- cache.StopPendingCountWrites(t.Context())
}() }()
// Had the stop cut off the write under way, its own write would wait for // The stop's own write waits for it once the goroutine has returned.
// the connection too. waitForConnectionWaits(t, cache.db, waitsBefore+2)
time.Sleep(otherWritesWait)
waits := cache.db.Stats().WaitCount - waitsBefore
t.Logf("%d writes waited for the database", waits)
if waits > 1 {
t.Errorf("%d writes waited for the database, want 1: "+
"the stop cut off the write under way", waits)
}
releaseDatabase() releaseDatabase()
@@ -274,54 +261,3 @@ func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) {
t.Errorf("counters = %+v, want %+v", got, want) t.Errorf("counters = %+v, want %+v", got, want)
} }
} }
// TestStopPendingCountWrites_ReturnsWhenItsContextEnds holds the database's
// one connection while the goroutine that writes the pending counts waits for
// it, then stops that goroutine with a context that ends first. The stop must
// return the context's error, and the write under way must still reach the
// database once the connection is free.
func TestStopPendingCountWrites_ReturnsWhenItsContextEnds(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
cache.StartPendingCountWrites()
defer func() { _ = cache.StopPendingCountWrites(t.Context()) }()
releaseDatabase := holdDatabase(t, cache)
defer releaseDatabase()
waitsBefore := cache.db.Stats().WaitCount
// Writes given a context past its deadline fail at once.
ended, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
cache.IncrementStats(ended, true, 0)
// The goroutine's write waits for the connection.
waitForConnectionWaits(t, cache.db, waitsBefore+1)
stopCtx, cancelStop := context.WithCancel(t.Context())
cancelStop()
stopped := make(chan error, 1)
go func() {
stopped <- cache.StopPendingCountWrites(stopCtx)
}()
select {
case err := <-stopped:
if !errors.Is(err, context.Canceled) {
t.Errorf("StopPendingCountWrites() error = %v, want %v",
err, context.Canceled)
}
case <-time.After(pendingCountsWait):
t.Fatal("StopPendingCountWrites() did not return once its context ended")
}
releaseDatabase()
waitForCounters(t, cache, cacheStatsCounters{hitCount: 1})
}
+12 -34
View File
@@ -11,8 +11,7 @@ import (
// however many requests pass their deadline before their counts are // however many requests pass their deadline before their counts are
// written, at most this one write waits for the database. It is a no-op // written, at most this one write waits for the database. It is a no-op
// when already started. The goroutine outlives the caller, so it runs with // when already started. The goroutine outlives the caller, so it runs with
// its own context, which StopPendingCountWrites cancels to tell it to // its own context, which StopPendingCountWrites cancels.
// finish.
func (c *Cache) StartPendingCountWrites() { func (c *Cache) StartPendingCountWrites() {
if c.pendingCountsCancel != nil { if c.pendingCountsCancel != nil {
return return
@@ -26,35 +25,24 @@ func (c *Cache) StartPendingCountWrites() {
}() }()
} }
// StopPendingCountWrites tells the goroutine StartPendingCountWrites // StopPendingCountWrites stops the goroutine StartPendingCountWrites
// started, if it did, to finish, and waits for its write under way to end // started, if it did, then writes the pending counts once more, so that
// and for it to return. It then writes the pending counts left, so that // they are written before the database closes. It waits for both at most
// they are written before the database closes. It waits for all of this at // until ctx ends. It logs the counts still unwritten then, which are lost,
// most until ctx ends, then logs the counts not written and returns an // and returns an error.
// error.
func (c *Cache) StopPendingCountWrites(ctx context.Context) error { func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
var err error
if c.pendingCountsCancel != nil { if c.pendingCountsCancel != nil {
c.pendingCountsCancel() c.pendingCountsCancel()
select { select {
case <-c.pendingCountsDone: case <-c.pendingCountsDone:
case <-ctx.Done(): case <-ctx.Done():
// The write given up on here may not reach the database:
// database/sql closes without waiting for a query, and a
// write still waiting for the connection fails when the
// database closes.
err = fmt.Errorf("pending counts still being written: %w", ctx.Err())
} }
} }
if err == nil { err := c.writePendingCounts(ctx)
err = c.writePendingCounts(ctx)
}
if err != nil { if err != nil {
c.log.Error("counts not written at shutdown", c.log.Error("counts lost at shutdown",
"hits", c.pendingHits.Load(), "hits", c.pendingHits.Load(),
"misses", c.pendingMisses.Load(), "misses", c.pendingMisses.Load(),
"upstream_fetches", c.pendingUpstreamFetches.Load(), "upstream_fetches", c.pendingUpstreamFetches.Load(),
@@ -70,9 +58,8 @@ func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
} }
// pendingCountWriteLoop is the body of the goroutine // pendingCountWriteLoop is the body of the goroutine
// StartPendingCountWrites starts. Once ctx is cancelled it starts no write // StartPendingCountWrites starts. It returns when ctx is cancelled. A
// and returns, but does not cut off a write under way then. A write that // write that fails leaves the counts pending, for the next write.
// fails leaves the counts pending, for the next write.
func (c *Cache) pendingCountWriteLoop(ctx context.Context) { func (c *Cache) pendingCountWriteLoop(ctx context.Context) {
defer close(c.pendingCountsDone) defer close(c.pendingCountsDone)
@@ -83,14 +70,8 @@ func (c *Cache) pendingCountWriteLoop(ctx context.Context) {
case <-c.pendingCountsAdded: case <-c.pendingCountsAdded:
} }
// select picks at random when ctx is cancelled and a wakeup err := c.writePendingCounts(ctx)
// waits too. if err != nil && ctx.Err() == nil {
if ctx.Err() != nil {
return
}
err := c.writePendingCounts(context.WithoutCancel(ctx))
if err != nil {
c.log.Warn("failed to write pending counts", "error", err) c.log.Warn("failed to write pending counts", "error", err)
} }
} }
@@ -112,9 +93,6 @@ func (c *Cache) addPendingCount(pendingCount *atomic.Int64, n int64) {
// UPDATE, then takes what it wrote out of them, leaving any added // UPDATE, then takes what it wrote out of them, leaving any added
// meanwhile. When the UPDATE fails, they all stay pending. // meanwhile. When the UPDATE fails, they all stay pending.
func (c *Cache) writePendingCounts(ctx context.Context) error { func (c *Cache) writePendingCounts(ctx context.Context) error {
c.pendingCountsWriteMutex.Lock()
defer c.pendingCountsWriteMutex.Unlock()
hits := c.pendingHits.Load() hits := c.pendingHits.Load()
misses := c.pendingMisses.Load() misses := c.pendingMisses.Load()
upstreamFetches := c.pendingUpstreamFetches.Load() upstreamFetches := c.pendingUpstreamFetches.Load()
+3 -5
View File
@@ -98,10 +98,9 @@ func (s *Middleware) ClientIP() func(http.Handler) http.Handler {
// holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the // holds whole; an IPv4-mapped address (::ffff:a.b.c.d) is counted as the
// IPv4 address it carries, since every such address falls in the same /64. // IPv4 address it carries, since every such address falls in the same /64.
// Counts are kept only for the current and the previous window, so memory // Counts are kept only for the current and the previous window, so memory
// stays bounded. limitHandler writes the 429 after Retry-After is set; nil // stays bounded.
// keeps the rate limit library's plain-text "Too Many Requests".
func (s *Middleware) RateLimit( func (s *Middleware) RateLimit(
requestLimit int, window time.Duration, limitHandler http.HandlerFunc, requestLimit int, window time.Duration,
) func(http.Handler) http.Handler { ) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, window, return httprate.LimitBy(requestLimit, window,
func(r *http.Request) (string, error) { func(r *http.Request) (string, error) {
@@ -113,8 +112,7 @@ func (s *Middleware) RateLimit(
} }
return httprate.CanonicalizeIP(ip), nil return httprate.CanonicalizeIP(ip), nil
}, })
httprate.WithLimitHandler(limitHandler))
} }
// requestIDPattern is what a request's own X-Request-Id must look like to be // requestIDPattern is what a request's own X-Request-Id must look like to be
@@ -1,204 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"sneak.berlin/go/pixa/internal/config"
)
// requestImageFrom sends method path to s from remoteAddr and returns the
// response. The image handlers refuse an unsigned image URL with 401 and a
// token they cannot decrypt with 400, so either status shows the limit let
// the request through.
func requestImageFrom(
t *testing.T, s *Server, method, path, remoteAddr string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, path, nil)
req.RemoteAddr = remoteAddr
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
return rec
}
// TestImageRequestLimitRefusesRequestOverLimit verifies that GET and HEAD on
// both image routes count toward one limit per client, and that the request
// after image_requests_per_minute of them is refused with 429 and a
// Retry-After header.
func TestImageRequestLimitRefusesRequestOverLimit(t *testing.T) {
t.Parallel()
requests := []struct {
method string
path string
want int
}{
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
{http.MethodGet, encryptedImagePath, http.StatusBadRequest},
}
s := newTestServer(t)
s.config.ImageRequestsPerMinute = len(requests)
s.SetupRoutes()
for _, tc := range requests {
rec := requestImageFrom(t, s, tc.method, tc.path, firstClient)
if rec.Code != tc.want {
t.Fatalf("%s %s within the limit status = %d, want %d",
tc.method, tc.path, rec.Code, tc.want)
}
}
rec := requestImageFrom(t, s, http.MethodHead, encryptedImagePath, firstClient)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("request over the limit status = %d, want %d",
rec.Code, http.StatusTooManyRequests)
}
retryAfter := rec.Header().Get("Retry-After")
seconds, err := strconv.Atoi(retryAfter)
if err != nil || seconds <= 0 {
t.Errorf("Retry-After = %q, want a positive number of seconds",
retryAfter)
}
}
// TestImageRequestLimitAnswersWithJSONError verifies that the 429 for a
// request over the limit carries Retry-After and the JSON error body the
// image routes send for their other errors.
func TestImageRequestLimitAnswersWithJSONError(t *testing.T) {
t.Parallel()
s := newTestServer(t)
s.config.ImageRequestsPerMinute = 1
s.SetupRoutes()
requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
t.Logf("status %d, body %s", rec.Code, rec.Body.String())
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("request over the limit status = %d, want %d",
rec.Code, http.StatusTooManyRequests)
}
retryAfter := rec.Header().Get("Retry-After")
seconds, err := strconv.Atoi(retryAfter)
if err != nil || seconds <= 0 {
t.Errorf("Retry-After = %q, want a positive number of seconds",
retryAfter)
}
contentType := rec.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Content-Type = %q, want %q", contentType, "application/json")
}
var body struct {
Error string `json:"error"`
Status int `json:"status"`
Timestamp string `json:"timestamp"`
}
err = json.NewDecoder(rec.Body).Decode(&body)
if err != nil {
t.Fatalf("body is not JSON: %v", err)
}
if body.Error == "" || body.Status != http.StatusTooManyRequests ||
body.Timestamp == "" {
t.Errorf("body = %+v, want an error, status %d and a timestamp",
body, http.StatusTooManyRequests)
}
}
// TestImageRequestLimitCountsClientsSeparately verifies that one client going
// over the limit does not limit another.
func TestImageRequestLimitCountsClientsSeparately(t *testing.T) {
t.Parallel()
s := newTestServer(t)
s.config.ImageRequestsPerMinute = 1
s.SetupRoutes()
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("first client's first request status = %d, want %d",
rec.Code, http.StatusUnauthorized)
}
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("first client's second request status = %d, want %d",
rec.Code, http.StatusTooManyRequests)
}
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, secondClient)
if rec.Code != http.StatusUnauthorized {
t.Errorf("second client's first request status = %d, want %d",
rec.Code, http.StatusUnauthorized)
}
}
// TestImageRequestLimitZeroLetsEveryRequestThrough verifies that with
// image_requests_per_minute set to 0 no image request is refused with 429,
// not even past the default limit.
func TestImageRequestLimitZeroLetsEveryRequestThrough(t *testing.T) {
t.Parallel()
s := newTestServer(t)
s.config.ImageRequestsPerMinute = 0
s.SetupRoutes()
for request := range config.DefaultImageRequestsPerMinute + 1 {
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("request %d status = %d, want %d",
request+1, rec.Code, http.StatusUnauthorized)
}
}
}
// TestImageRequestLimitDoesNotCountPreflight verifies that a browser's CORS
// preflight request to an image route is answered without being counted, so
// a client that has sent several still gets its image request through.
func TestImageRequestLimitDoesNotCountPreflight(t *testing.T) {
t.Parallel()
s := newTestServer(t)
s.config.AccessControlAllowOrigin = config.DefaultAccessControlAllowOrigin
s.config.ImageRequestsPerMinute = 1
s.SetupRoutes()
for range 3 {
req := httptest.NewRequestWithContext(
t.Context(), http.MethodOptions, unsignedImagePath, nil)
req.RemoteAddr = firstClient
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("preflight status = %d, want %d", rec.Code, http.StatusOK)
}
}
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
if rec.Code != http.StatusUnauthorized {
t.Errorf("image request after preflights status = %d, want %d",
rec.Code, http.StatusUnauthorized)
}
}
+1 -10
View File
@@ -66,7 +66,7 @@ func (s *Server) SetupRoutes() {
r.Use(s.h.LimitBody(handlers.MaxFormBytes)) r.Use(s.h.LimitBody(handlers.MaxFormBytes))
r.Use(s.h.CSRF()) r.Use(s.h.CSRF())
r.Get("/", s.h.HandleRoot()) r.Get("/", s.h.HandleRoot())
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute, nil)). r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute)).
Post("/", s.h.HandleRoot()) Post("/", s.h.HandleRoot())
r.Post("/generate", s.h.HandleGenerateURL()) r.Post("/generate", s.h.HandleGenerateURL())
}) })
@@ -88,15 +88,6 @@ func (s *Server) SetupRoutes() {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(s.refuseDuringMaintenance) r.Use(s.refuseDuringMaintenance)
// Limited per client, as the login POST is, but the 429 has
// the image routes' JSON error body; 0 turns the limit off.
// The CORS middleware answers a preflight before it gets
// here, so a preflight is not counted.
if s.config.ImageRequestsPerMinute > 0 {
r.Use(s.mw.RateLimit(s.config.ImageRequestsPerMinute,
time.Minute, s.h.HandleTooManyRequests()))
}
// Main image proxy route // Main image proxy route
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no // /v1/image/<host>/<path>/<width>x<height>.<format>, or with no
// format /v1/image/<host>/<path>/<width>x<height> // format /v1/image/<host>/<path>/<width>x<height>
+1 -3
View File
@@ -94,8 +94,7 @@ main() {
} }
# start_containers starts a new origin and a new pixad, and waits up to 30 # start_containers starts a new origin and a new pixad, and waits up to 30
# seconds for pixad's health check to pass. pixad sees all of vegeta's # seconds for pixad's health check to pass.
# requests come from one address, so its image request limit is off.
start_containers() { start_containers() {
docker run -d --name "$run-origin" \ docker run -d --name "$run-origin" \
--network "$run" --network-alias origin \ --network "$run" --network-alias origin \
@@ -105,7 +104,6 @@ start_containers() {
--network "$run" -p 127.0.0.1::8080 --health-interval=1s \ --network "$run" -p 127.0.0.1::8080 --health-interval=1s \
-e PIXA_SIGNING_KEY="$(head -c 32 /dev/urandom | base64)" \ -e PIXA_SIGNING_KEY="$(head -c 32 /dev/urandom | base64)" \
-e PIXA_ALLOWLIST_HOSTS=origin -e PIXA_ALLOW_HTTP=true \ -e PIXA_ALLOWLIST_HOSTS=origin -e PIXA_ALLOW_HTTP=true \
-e PIXA_IMAGE_REQUESTS_PER_MINUTE=0 \
"$image" >/dev/null "$image" >/dev/null
waited=0 waited=0