Answer image requests with 503 in maintenance mode (closes #71)
check / check (push) Successful in 2m54s
check / check (push) Successful in 2m54s
maintenance_mode was only reported by the health check; every request was still served. One middleware in routes.go, applied to /v1/image/ and /v1/e/ only, now answers them with 503, a Retry-After of MaintenanceRetryAfterSeconds and the JSON error body while it is on. It calls Server.MaintenanceMode(), which had no caller. The health check stays 200 and reports maintenance_mode: the image's Docker HEALTHCHECK requests it, a 503 there would make the container unhealthy, and upaas marks a deploy failed when its container is unhealthy. The login and URL generator pages and /metrics keep working. Documented in README.md and config.example.yml. Model: opus-5-5
This commit is contained in:
@@ -239,7 +239,7 @@ variables set by the file's `env:` section are checked the same way.
|
|||||||
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
|
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
|
||||||
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
|
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
|
||||||
| `PIXA_DEBUG` | `debug` | Debug logging and plain-HTTP local development; default `false` |
|
| `PIXA_DEBUG` | `debug` | Debug logging and plain-HTTP local development; default `false` |
|
||||||
| `PIXA_MAINTENANCE_MODE` | `maintenance_mode` | Maintenance flag reported by the health check; default `false` |
|
| `PIXA_MAINTENANCE_MODE` | `maintenance_mode` | Answer image requests with 503; the health check stays 200; default `false` |
|
||||||
|
|
||||||
Key settings in more detail:
|
Key settings in more detail:
|
||||||
|
|
||||||
@@ -276,6 +276,13 @@ Key settings in more detail:
|
|||||||
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
|
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the
|
||||||
disk cache entirely; omitted defaults to 75% of the free space on
|
disk cache entirely; omitted defaults to 75% of the free space on
|
||||||
the filesystem containing `<state_dir>/cache/` (minimum 500 MiB)
|
the filesystem containing `<state_dir>/cache/` (minimum 500 MiB)
|
||||||
|
- `maintenance_mode` — while `true`, the image routes (`/v1/image/` and
|
||||||
|
`/v1/e/`) answer every request with 503, a `Retry-After` header and a JSON
|
||||||
|
error body. The health check (`/.well-known/healthcheck.json`) still answers
|
||||||
|
200 and reports `"maintenance_mode": true`. It stays 200 because the image's
|
||||||
|
Docker `HEALTHCHECK` requests it: a 503 there would make the container
|
||||||
|
unhealthy, and upaas marks a deploy failed when its container is unhealthy.
|
||||||
|
The login and URL generator pages and `/metrics` keep working
|
||||||
|
|
||||||
See `config.example.yml` for all options with defaults.
|
See `config.example.yml` for all options with defaults.
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ exhaustion
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-29 maintenance mode refuses image requests (closes #71): while
|
||||||
|
`maintenance_mode` is on, `/v1/image/` and `/v1/e/` answer 503 with a
|
||||||
|
`Retry-After` header and the JSON error body, from one middleware in
|
||||||
|
`internal/server/routes.go`; the health check stays 200 and reports
|
||||||
|
`maintenance_mode`, as the image's Docker `HEALTHCHECK` requests it and upaas
|
||||||
|
marks a deploy failed when its container is unhealthy; the login and URL
|
||||||
|
generator pages and `/metrics` keep working; documented in `README.md` and
|
||||||
|
`config.example.yml`.
|
||||||
- 2026-09-29 `trusted_proxies` advice and signature padding in `README.md`
|
- 2026-09-29 `trusted_proxies` advice and signature padding in `README.md`
|
||||||
(closes #150): the login-limit paragraph, the `trusted_proxies` entry and
|
(closes #150): the login-limit paragraph, the `trusted_proxies` entry and
|
||||||
`config.example.yml` say to set `trusted_proxies` to the address pixa sees for
|
`config.example.yml` say to set `trusted_proxies` to the address pixa sees for
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
# Server settings
|
# Server settings
|
||||||
port: 8080
|
port: 8080
|
||||||
debug: false
|
debug: false
|
||||||
|
|
||||||
|
# While true, the image routes (/v1/image/ and /v1/e/) answer every request
|
||||||
|
# with 503 and a Retry-After header. The health check keeps answering 200 and
|
||||||
|
# reports maintenance_mode as true. It stays 200 because the image's Docker
|
||||||
|
# HEALTHCHECK requests it: a 503 there would make the container unhealthy, and
|
||||||
|
# upaas marks a deploy failed when its container is unhealthy.
|
||||||
maintenance_mode: false
|
maintenance_mode: false
|
||||||
|
|
||||||
# Data directory for SQLite database and cache files
|
# Data directory for SQLite database and cache files
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
@@ -17,6 +19,10 @@ import (
|
|||||||
// make per minute; the next is refused with 429 Too Many Requests.
|
// make per minute; the next is refused with 429 Too Many Requests.
|
||||||
const LoginAttemptsPerMinute = 5
|
const LoginAttemptsPerMinute = 5
|
||||||
|
|
||||||
|
// MaintenanceRetryAfterSeconds is the Retry-After, in seconds, sent with
|
||||||
|
// the 503 that the image routes answer while maintenance mode is on.
|
||||||
|
const MaintenanceRetryAfterSeconds = 300
|
||||||
|
|
||||||
// SetupRoutes configures all HTTP routes.
|
// SetupRoutes configures all HTTP routes.
|
||||||
func (s *Server) SetupRoutes() {
|
func (s *Server) SetupRoutes() {
|
||||||
s.router = chi.NewRouter()
|
s.router = chi.NewRouter()
|
||||||
@@ -68,15 +74,23 @@ func (s *Server) SetupRoutes() {
|
|||||||
|
|
||||||
s.router.Get("/logout", s.h.HandleLogout())
|
s.router.Get("/logout", s.h.HandleLogout())
|
||||||
|
|
||||||
|
// Image routes, refused while maintenance mode is on. Only these: the
|
||||||
|
// image's Docker HEALTHCHECK requests the health check, a 503 there
|
||||||
|
// would make the container unhealthy, and upaas marks a deploy failed
|
||||||
|
// when its container is unhealthy.
|
||||||
|
s.router.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.refuseDuringMaintenance)
|
||||||
|
|
||||||
// Main image proxy route
|
// Main image proxy route
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||||
s.router.Get("/v1/image/*", s.h.HandleImage())
|
r.Get("/v1/image/*", s.h.HandleImage())
|
||||||
s.router.Head("/v1/image/*", s.h.HandleImage())
|
r.Head("/v1/image/*", s.h.HandleImage())
|
||||||
|
|
||||||
// Encrypted image URL route
|
// Encrypted image URL route
|
||||||
// The trailing filename (e.g., /img.jpg) is ignored but helps
|
// The trailing filename (e.g., /img.jpg) is ignored but helps
|
||||||
// browsers with content type
|
// browsers with content type
|
||||||
s.router.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
|
r.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
|
||||||
|
})
|
||||||
|
|
||||||
// Metrics endpoint with auth
|
// Metrics endpoint with auth
|
||||||
if s.config.MetricsUsername != "" {
|
if s.config.MetricsUsername != "" {
|
||||||
@@ -86,3 +100,30 @@ func (s *Server) SetupRoutes() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refuseDuringMaintenance answers a request with 503 Service Unavailable,
|
||||||
|
// a Retry-After header and a JSON error body while maintenance mode is on,
|
||||||
|
// and passes it on otherwise. The body has the fields of the JSON errors
|
||||||
|
// the image handlers send.
|
||||||
|
func (s *Server) refuseDuringMaintenance(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.MaintenanceMode() {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(MaintenanceRetryAfterSeconds))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
|
||||||
|
err := json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"error": "down for maintenance, try again later",
|
||||||
|
"status": http.StatusServiceUnavailable,
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("json encode error", "error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user