Merge remote-tracking branch 'origin/main' into up-payer

# Conflicts:
#	server/errors.go
This commit is contained in:
binwiederhier committed 2026-11-01 20:30:27 +00:00
commit 5bd36bdcd9
11 files changed
+402 -159

No files matched your search

+9
View File
@@ -1929,6 +1929,15 @@ visitor's bucket has refilled enough (e.g. for the request, e-mail or topic crea
day is over for a daily limit (e.g. messages, e-mails, phone calls or attachment bandwidth). Limits without a known
wait, such as the subscription limit or the global topic limit, do not send the header.
Publish responses, and every 429 caused by one of these limits, also carry the `RateLimit-Policy` and `RateLimit` headers of the IETF draft
[RateLimit header fields for HTTP](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
(draft-ietf-httpapi-ratelimit-headers-11), so clients can slow down before they hit a limit. There is one policy for the
request limit (`q` is the burst, `r` the requests left, `t` the seconds until the next request is refilled) and one for
the daily message limit (`q` is the daily limit, `r` the messages left today, `t` the seconds until the visitor's day is
over), e.g. `RateLimit-Policy: "messages";q=250;w=86400` and `RateLimit: "messages";r=249;t=3600`. Exempt visitors (see
`visitor-request-limit-exempt-hosts`) and unlimited limits do not get a policy. With multiple ntfy nodes, the remaining
daily messages are the answering node's view, which may lag the others' by a few seconds.
### General limits
Let's do the easy limits first:
+1
View File
@@ -2320,6 +2320,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
* With `visitor-subscriber-rate-limiting`, the subscriber of a UnifiedPush topic (who pays for its pushes) is kept in the user database and shared by all servers, so a push to any server is billed to the subscriber instead of answered with 507 (see [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting))
* Send a `Retry-After` header with HTTP 429 responses that are caused by a rate limit with a known wait, e.g. the request, e-mail or daily message limits (see [rate limiting](config.md#rate-limiting))
* Send the IETF draft `RateLimit-Policy` and `RateLimit` headers with publish responses, for the request and daily message limits (see [rate limiting](config.md#rate-limiting))
**Behavior changes:**
+51 -7
View File
@@ -102,13 +102,21 @@ func newService(store *Store, now func() time.Time) *Service {
// bucket is charged first and handed back if the daily cap denies, so the two locks are taken
// one after the other, never nested.
func (s *Service) Allow(key string, rule Rule, n int64) Decision {
d := s.allow(key, rule, n)
d.Kind = rule.Kind
return d
}
// allow is Allow, without naming the decision's kind
func (s *Service) allow(key string, rule Rule, n int64) Decision {
now := s.now()
if d := s.takeTokens(key, &rule, n, now); !d.OK {
d := s.takeTokens(key, &rule, n, now)
if !d.OK {
return d
}
i := dailyIndex(rule.Kind)
if i < 0 {
return Decision{OK: true}
return d
}
start := periodStart(key, now)
cs := s.counters[shardIndex(key)]
@@ -121,14 +129,33 @@ func (s *Service) Allow(key string, rule Rule, n int64) Decision {
if rule.Burst > 0 {
s.refundTokens(key, rule.Kind, n)
}
return Decision{RetryAfter: end.Sub(now)}
return dailyDecision(false, rule.Daily, total, end.Sub(now))
}
c.unflushed[i] += n
if rule.Threshold == 0 || total+n >= rule.Threshold {
c.promoted = true
}
end := time.Unix(c.periodStart+daySeconds, 0)
cs.mu.Unlock()
return Decision{OK: true}
if rule.Daily <= 0 {
return d
}
return dailyDecision(true, rule.Daily, total+n, end.Sub(now))
}
// dailyDecision describes the daily cap after used units, with reset until the period ends
func dailyDecision(ok bool, daily, used int64, reset time.Duration) Decision {
d := Decision{
OK: ok,
Quota: daily,
Window: oneDay,
Remaining: max(0, daily-used),
Reset: reset,
}
if !ok {
d.RetryAfter = reset
}
return d
}
// Start loads the current counts from the store, if there is one, and starts the sync worker.
@@ -190,6 +217,13 @@ func (s *Service) Tokens(key string, rule Rule) int64 {
// it. Like Tokens, a rule without a bucket admits nothing. It is for "may I try" checks that charge
// only on failure, and still need to tell the client how long to wait.
func (s *Service) Check(key string, rule Rule) Decision {
d := s.check(key, rule)
d.Kind = rule.Kind
return d
}
// check is Check, without naming the decision's kind
func (s *Service) check(key string, rule Rule) Decision {
if rule.Burst <= 0 {
return Decision{}
}
@@ -493,14 +527,14 @@ func (s *Service) takeTokens(key string, rule *Rule, n int64, now time.Time) Dec
b := &e.buckets[rule.Kind]
b.refill(now, rule)
if n > rule.Burst {
return Decision{} // Can never fit, so there is no time to wait for
return b.decision(Decision{}, rule) // Can never fit, so there is no time to wait for
} else if b.tokens < float64(n) {
return Decision{RetryAfter: b.wait(n, rule)}
return b.decision(Decision{RetryAfter: b.wait(n, rule)}, rule)
}
b.tokens -= float64(n)
e.noteFull(b, rule)
e.countNoLock(key, rule.Kind, n, now)
return Decision{OK: true}
return b.decision(Decision{OK: true}, rule)
}
// refundTokens hands n tokens back to the key's bucket of the kind, up to its burst
@@ -614,6 +648,16 @@ func (b *bucket) refill(now time.Time, rule *Rule) {
b.burst = rule.Burst
}
// decision adds the bucket's quota to d: the whole tokens left, and the time until the next one,
// within which the client can use no more than those (the "t" of the RateLimit header)
func (b *bucket) decision(d Decision, rule *Rule) Decision {
d.Quota = rule.Burst
d.Window = time.Duration(rule.Burst) * rule.Refill
d.Remaining = max(0, int64(math.Floor(b.tokens)))
d.Reset = b.wait(d.Remaining+1, rule)
return d
}
// wait returns how long until the bucket has n tokens, or 0 if it never will (no refill)
func (b *bucket) wait(n int64, rule *Rule) time.Duration {
if rule.Refill <= 0 {
+61 -5
View File
@@ -117,6 +117,62 @@ func TestService_Allow_BucketAndDaily(t *testing.T) {
require.Equal(t, int64(3), s.Usage("k").Count(KindEmails))
}
func TestService_Allow_QuotaBucket(t *testing.T) {
// The decision describes the bucket: whole tokens left, and the time to the next token
s, clock := newTestServiceWithClock(t)
rule := Rule{
Kind: KindRequests,
Burst: 3,
Refill: 100 * time.Millisecond,
}
expected := Decision{
OK: true,
Kind: KindRequests,
Quota: 3,
Window: 300 * time.Millisecond,
Remaining: 2,
Reset: 100 * time.Millisecond,
}
require.Equal(t, expected, s.Allow("k", rule, 1))
s.Allow("k", rule, 1)
expected.Remaining = 0
require.Equal(t, expected, s.Allow("k", rule, 1))
clock.advance(40 * time.Millisecond)
expected = Decision{
RetryAfter: 60 * time.Millisecond,
Kind: KindRequests,
Quota: 3,
Window: 300 * time.Millisecond,
Remaining: 0,
Reset: 60 * time.Millisecond, // Matches RetryAfter
}
require.Equal(t, expected, s.Allow("k", rule, 1))
}
func TestService_Allow_QuotaDaily(t *testing.T) {
// The decision describes the daily cap: units left, and the time until the period ends
s, _ := newTestServiceWithClock(t) // Noon UTC; "k" resets at 03:48:26 UTC
rule := Rule{
Kind: KindMessages,
Daily: 2,
}
reset := 15*time.Hour + 48*time.Minute + 26*time.Second
expected := Decision{
OK: true,
Kind: KindMessages,
Quota: 2,
Window: 24 * time.Hour,
Remaining: 1,
Reset: reset,
}
require.Equal(t, expected, s.Allow("k", rule, 1))
expected.Remaining = 0
require.Equal(t, expected, s.Allow("k", rule, 1))
expected.OK = false
expected.RetryAfter = reset
require.Equal(t, expected, s.Allow("k", rule, 1))
}
func TestService_Allow_DailyDenialKeepsTokens(t *testing.T) {
// A call the daily cap denies does not spend the bucket's tokens
s, _ := newTestServiceWithClock(t)
@@ -279,16 +335,16 @@ func TestService_Check(t *testing.T) {
Burst: 2,
Refill: time.Minute,
}
require.Equal(t, Decision{OK: true}, s.Check("k", rule)) // A fresh bucket is full
require.Equal(t, Decision{OK: true, Kind: KindAuth}, s.Check("k", rule)) // A fresh bucket is full
require.True(t, s.Allow("k", rule, 2).OK)
require.Equal(t, Decision{RetryAfter: time.Minute}, s.Check("k", rule))
require.Equal(t, Decision{RetryAfter: time.Minute, Kind: KindAuth}, s.Check("k", rule))
clock.advance(20 * time.Second)
require.Equal(t, Decision{RetryAfter: 40 * time.Second}, s.Check("k", rule))
require.Equal(t, Decision{RetryAfter: 40 * time.Second, Kind: KindAuth}, s.Check("k", rule))
require.Equal(t, int64(0), s.Tokens("k", rule)) // Checking did not take anything
clock.advance(40 * time.Second)
require.Equal(t, Decision{OK: true}, s.Check("k", rule))
require.Equal(t, Decision{OK: true, Kind: KindAuth}, s.Check("k", rule))
require.Equal(t, int64(1), s.Tokens("k", rule))
require.Equal(t, Decision{}, s.Check("k", Rule{Kind: KindAuth})) // No bucket admits nothing, as Tokens says 0
require.Equal(t, Decision{Kind: KindAuth}, s.Check("k", Rule{Kind: KindAuth})) // No bucket admits nothing, as Tokens says 0
}
func TestService_Prune(t *testing.T) {
+9 -3
View File
@@ -42,10 +42,16 @@ type Rule struct {
}
// Decision is the answer to Allow: whether the usage was admitted, and if not, how long until
// it could be (0 if unknown, e.g. when n exceeds the burst)
// it could be (0 if unknown, e.g. when n exceeds the burst). The other fields describe the limit
// that decided, for the RateLimit headers: the daily cap if the rule has one, else the bucket.
type Decision struct {
OK bool
RetryAfter time.Duration
OK bool // Whether the usage was admitted; no header
RetryAfter time.Duration // Retry-After (on a 429): how long until the usage could be admitted
Kind Kind // RateLimit-Policy and RateLimit: the policy name ("messages"), the rule's kind
Quota int64 // RateLimit-Policy q=: the daily cap or the burst; 0 if the rule has neither (no headers)
Window time.Duration // RateLimit-Policy w=: a day, or the time the bucket takes to refill completely
Remaining int64 // RateLimit r=: units left after this call
Reset time.Duration // RateLimit t=: until the period ends, or until the bucket's next token (0 if it never refills)
}
// Usage is a key's daily counts in its current period
+123 -122
View File
@@ -6,19 +6,19 @@ import (
"math"
"net/http"
"strconv"
"time"
"heckel.io/ntfy/v2/limit"
"heckel.io/ntfy/v2/log"
)
// errHTTP is a generic HTTP error for any non-200 HTTP error
type errHTTP struct {
Code int `json:"code,omitempty"`
HTTPCode int `json:"http"`
Message string `json:"error"`
Link string `json:"link,omitempty"`
context log.Context
retryAfter time.Duration // How long until a 429 would be admitted, sent as Retry-After; 0 if unknown
Code int `json:"code,omitempty"`
HTTPCode int `json:"http"`
Message string `json:"error"`
Link string `json:"link,omitempty"`
context log.Context
decision *limit.Decision // The limit decision behind a 429, sent as Retry-After and RateLimit headers; nil if none
}
func (e errHTTP) Error() string {
@@ -68,10 +68,11 @@ func (e errHTTP) Fields(context log.Context) *errHTTP {
return &c
}
// RetryAfter returns a copy of the error that tells the client to retry after d (see limit.Decision)
func (e errHTTP) RetryAfter(d time.Duration) *errHTTP {
// WithLimitDecision returns a copy of the error that carries the limit decision that denied the
// request, so the response tells the client when to retry and how much of the limit is left
func (e errHTTP) WithLimitDecision(d limit.Decision) *errHTTP {
c := e.clone()
c.retryAfter = d
c.decision = &d
return &c
}
@@ -81,22 +82,22 @@ func (e errHTTP) clone() errHTTP {
context[k] = v
}
return errHTTP{
Code: e.Code,
HTTPCode: e.HTTPCode,
Message: e.Message,
Link: e.Link,
context: context,
retryAfter: e.retryAfter,
Code: e.Code,
HTTPCode: e.HTTPCode,
Message: e.Message,
Link: e.Link,
context: context,
decision: e.decision,
}
}
// retryAfterSeconds is the Retry-After header value: whole seconds rounded up, so a client never
// retries too early, or "" if the wait is unknown
func (e errHTTP) retryAfterSeconds() string {
if e.retryAfter <= 0 {
if e.decision == nil || e.decision.RetryAfter <= 0 {
return ""
}
return strconv.FormatInt(int64(math.Ceil(e.retryAfter.Seconds())), 10)
return strconv.FormatInt(int64(math.Ceil(e.decision.RetryAfter.Seconds())), 10)
}
// errWebSocketPostUpgrade is a wrapper error indicating an error occurred after the WebSocket
@@ -115,108 +116,108 @@ func (e *errWebSocketPostUpgrade) Unwrap() error {
}
var (
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil, 0}
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil, 0}
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", "", nil, 0}
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", "", nil, 0}
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil, 0}
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil, 0}
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil, 0}
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority", nil, 0}
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil, 0}
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil, 0}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil, 0}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid request: message must be UTF-8 encoded", "", nil, 0}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil, 0}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil, 0}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil, 0}
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets", nil, 0}
errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json", nil, 0}
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons", nil, 0}
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway", nil, 0}
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons", nil, 0}
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config", nil, 0}
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", "", nil, 0}
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", "", nil, 0}
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", "", nil, 0}
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", "", nil, 0}
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil, 0}
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil, 0}
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil, 0}
errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil, 0}
errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil, 0}
errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil, 0}
errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil, 0}
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil, 0}
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil, 0}
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil, 0}
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "invalid request: delayed call notifications are not supported", "", nil, 0}
errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil, 0}
errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil, 0}
errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil, 0}
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil, 0}
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil, 0}
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil, 0}
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, define, or block", "https://ntfy.sh/docs/publish/#message-templating", nil, 0}
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil, 0}
errHTTPBadRequestTemplateExecutionTimeout = &errHTTP{40055, http.StatusBadRequest, "invalid request: template execution timed out", "https://ntfy.sh/docs/publish/#message-templating", nil, 0}
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil, 0}
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil, 0}
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil, 0}
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil, 0}
errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil, 0}
errHTTPBadRequestEmailVerificationLinkInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil, 0}
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil, 0}
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil, 0}
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil, 0}
errHTTPBadRequestTemplateTooLarge = &errHTTP{40056, http.StatusBadRequest, "invalid request: template too large", "https://ntfy.sh/docs/publish/#message-templating", nil, 0}
errHTTPBadRequestTitleTooLarge = &errHTTP{40057, http.StatusBadRequest, "invalid request: title is too large", "https://ntfy.sh/docs/publish/#limitations", nil, 0}
errHTTPBadRequestTagsTooLarge = &errHTTP{40058, http.StatusBadRequest, "invalid request: tags are too large", "https://ntfy.sh/docs/publish/#limitations", nil, 0}
errHTTPBadRequestTimezoneInvalid = &errHTTP{40059, http.StatusBadRequest, "invalid request: invalid timezone", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil, 0}
errHTTPBadRequestPhoneVerificationCodeInvalid = &errHTTP{40060, http.StatusBadRequest, "invalid request: phone number verification code invalid", "", nil, 0}
errHTTPBadRequestTooManyTopics = &errHTTP{40061, http.StatusBadRequest, "invalid request: too many topics", "https://ntfy.sh/docs/subscribe/api/#subscribe-to-multiple-topics", nil, 0}
errHTTPBadRequestTierCodeInvalid = &errHTTP{40062, http.StatusBadRequest, "invalid request: tier code invalid, or tier name missing", "", nil, 0}
errHTTPBadRequestTierReservationsLimit = &errHTTP{40063, http.StatusBadRequest, "invalid request: user has more topic reservations than the tier allows, remove reservations first", "", nil, 0}
errHTTPBadRequestAccessAdminUser = &errHTTP{40064, http.StatusBadRequest, "invalid request: admins can access all topics, access control entries are not allowed", "", nil, 0}
errHTTPBadRequestTokenNotFound = &errHTTP{40065, http.StatusBadRequest, "invalid request: token does not exist", "", nil, 0}
errHTTPBadRequestRoleInvalid = &errHTTP{40066, http.StatusBadRequest, "invalid request: role must be user or admin", "", nil, 0}
errHTTPBadRequestUserNoPrimaryEmail = &errHTTP{40067, http.StatusBadRequest, "invalid request: user has no primary email, cannot send reset link", "", nil, 0}
errHTTPBadRequestBaseURLNotConfigured = &errHTTP{40068, http.StatusBadRequest, "invalid request: base-url must be configured on the server to generate links", "https://ntfy.sh/docs/config/", nil, 0}
errHTTPBadRequestPasswordHashInvalid = &errHTTP{40069, http.StatusBadRequest, "invalid request: password hash must be a bcrypt hash with cost 10 or higher, use 'ntfy user hash' to generate", "", nil, 0}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil, 0}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil, 0}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil, 0}
errHTTPForbiddenAdminSocketOnly = &errHTTP{40302, http.StatusForbidden, "forbidden: this operation is only allowed over the local admin socket", "https://ntfy.sh/docs/config/#admin-socket", nil, 0}
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil, 0}
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil, 0}
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil, 0}
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil, 0}
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil, 0}
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil, 0}
errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil, 0}
errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the primary email on another account", "", nil, 0}
errHTTPConflictTierExists = &errHTTP{40909, http.StatusConflict, "conflict: tier already exists", "", nil, 0}
errHTTPConflictTierInUse = &errHTTP{40910, http.StatusConflict, "conflict: tier is still assigned to users", "", nil, 0}
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil, 0}
errHTTPGoneUnifiedPushTopicAbandoned = &errHTTP{41002, http.StatusGone, "UnifiedPush topic has had no subscriber for too long, subscription expired", "", nil, 0}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil, 0}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil, 0}
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil, 0}
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests", "https://ntfy.sh/docs/publish/#limitations", nil, 0}
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails", "https://ntfy.sh/docs/publish/#limitations", nil, 0}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil, 0}
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil, 0}
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil, 0}
errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil, 0} // FIXME document limit
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil, 0}
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil, 0}
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil, 0} // FIXME document limit
errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil, 0}
errHTTPTooManyRequestsLimitTopicCreation = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: too many new topics, please wait", "https://ntfy.sh/docs/publish/#limitations", nil, 0}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil, 0}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil, 0}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil, 0}
errHTTPInternalErrorWebPushUnableToPublish = &errHTTP{50004, http.StatusInternalServerError, "internal server error: unable to publish web push message", "", nil, 0}
errHTTPServiceUnavailableDraining = &errHTTP{50301, http.StatusServiceUnavailable, "service unavailable: this node is shutting down, please reconnect", "", nil, 0}
errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil, 0}
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil, nil}
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil, nil}
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", "", nil, nil}
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", "", nil, nil}
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil, nil}
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil, nil}
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil, nil}
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority", nil, nil}
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil, nil}
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil, nil}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil, nil}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid request: message must be UTF-8 encoded", "", nil, nil}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil, nil}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil, nil}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil, nil}
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets", nil, nil}
errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json", nil, nil}
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons", nil, nil}
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway", nil, nil}
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons", nil, nil}
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config", nil, nil}
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", "", nil, nil}
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", "", nil, nil}
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", "", nil, nil}
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", "", nil, nil}
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil, nil}
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil, nil}
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil, nil}
errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil, nil}
errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil, nil}
errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil, nil}
errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil, nil}
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil, nil}
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil, nil}
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil, nil}
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "invalid request: delayed call notifications are not supported", "", nil, nil}
errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil, nil}
errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil, nil}
errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil, nil}
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil, nil}
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil, nil}
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil, nil}
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, define, or block", "https://ntfy.sh/docs/publish/#message-templating", nil, nil}
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil, nil}
errHTTPBadRequestTemplateExecutionTimeout = &errHTTP{40055, http.StatusBadRequest, "invalid request: template execution timed out", "https://ntfy.sh/docs/publish/#message-templating", nil, nil}
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil, nil}
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil, nil}
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil, nil}
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil, nil}
errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil, nil}
errHTTPBadRequestEmailVerificationLinkInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil, nil}
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil, nil}
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil, nil}
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil, nil}
errHTTPBadRequestTemplateTooLarge = &errHTTP{40056, http.StatusBadRequest, "invalid request: template too large", "https://ntfy.sh/docs/publish/#message-templating", nil, nil}
errHTTPBadRequestTitleTooLarge = &errHTTP{40057, http.StatusBadRequest, "invalid request: title is too large", "https://ntfy.sh/docs/publish/#limitations", nil, nil}
errHTTPBadRequestTagsTooLarge = &errHTTP{40058, http.StatusBadRequest, "invalid request: tags are too large", "https://ntfy.sh/docs/publish/#limitations", nil, nil}
errHTTPBadRequestTimezoneInvalid = &errHTTP{40059, http.StatusBadRequest, "invalid request: invalid timezone", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil, nil}
errHTTPBadRequestPhoneVerificationCodeInvalid = &errHTTP{40060, http.StatusBadRequest, "invalid request: phone number verification code invalid", "", nil, nil}
errHTTPBadRequestTooManyTopics = &errHTTP{40061, http.StatusBadRequest, "invalid request: too many topics", "https://ntfy.sh/docs/subscribe/api/#subscribe-to-multiple-topics", nil, nil}
errHTTPBadRequestTierCodeInvalid = &errHTTP{40062, http.StatusBadRequest, "invalid request: tier code invalid, or tier name missing", "", nil, nil}
errHTTPBadRequestTierReservationsLimit = &errHTTP{40063, http.StatusBadRequest, "invalid request: user has more topic reservations than the tier allows, remove reservations first", "", nil, nil}
errHTTPBadRequestAccessAdminUser = &errHTTP{40064, http.StatusBadRequest, "invalid request: admins can access all topics, access control entries are not allowed", "", nil, nil}
errHTTPBadRequestTokenNotFound = &errHTTP{40065, http.StatusBadRequest, "invalid request: token does not exist", "", nil, nil}
errHTTPBadRequestRoleInvalid = &errHTTP{40066, http.StatusBadRequest, "invalid request: role must be user or admin", "", nil, nil}
errHTTPBadRequestUserNoPrimaryEmail = &errHTTP{40067, http.StatusBadRequest, "invalid request: user has no primary email, cannot send reset link", "", nil, nil}
errHTTPBadRequestBaseURLNotConfigured = &errHTTP{40068, http.StatusBadRequest, "invalid request: base-url must be configured on the server to generate links", "https://ntfy.sh/docs/config/", nil, nil}
errHTTPBadRequestPasswordHashInvalid = &errHTTP{40069, http.StatusBadRequest, "invalid request: password hash must be a bcrypt hash with cost 10 or higher, use 'ntfy user hash' to generate", "", nil, nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil, nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil, nil}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil, nil}
errHTTPForbiddenAdminSocketOnly = &errHTTP{40302, http.StatusForbidden, "forbidden: this operation is only allowed over the local admin socket", "https://ntfy.sh/docs/config/#admin-socket", nil, nil}
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil, nil}
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil, nil}
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil, nil}
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil, nil}
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil, nil}
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil, nil}
errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil, nil}
errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the primary email on another account", "", nil, nil}
errHTTPConflictTierExists = &errHTTP{40909, http.StatusConflict, "conflict: tier already exists", "", nil, nil}
errHTTPConflictTierInUse = &errHTTP{40910, http.StatusConflict, "conflict: tier is still assigned to users", "", nil, nil}
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil, nil}
errHTTPGoneUnifiedPushTopicAbandoned = &errHTTP{41002, http.StatusGone, "UnifiedPush topic has had no subscriber for too long, subscription expired", "", nil, nil}
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil, nil}
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil, nil}
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil, nil}
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests", "https://ntfy.sh/docs/publish/#limitations", nil, nil}
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails", "https://ntfy.sh/docs/publish/#limitations", nil, nil}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil, nil}
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil, nil}
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil, nil}
errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil, nil} // FIXME document limit
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil, nil}
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil, nil}
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil, nil} // FIXME document limit
errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil, nil}
errHTTPTooManyRequestsLimitTopicCreation = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: too many new topics, please wait", "https://ntfy.sh/docs/publish/#limitations", nil, nil}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil, nil}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil, nil}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil, nil}
errHTTPInternalErrorWebPushUnableToPublish = &errHTTP{50004, http.StatusInternalServerError, "internal server error: unable to publish web push message", "", nil, nil}
errHTTPServiceUnavailableDraining = &errHTTP{50301, http.StatusServiceUnavailable, "service unavailable: this node is shutting down, please reconnect", "", nil, nil}
errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil, nil}
)
+19 -12
View File
@@ -719,6 +719,9 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
if retryAfter := httpErr.retryAfterSeconds(); retryAfter != "" {
w.Header().Set("Retry-After", retryAfter)
}
if httpErr.decision != nil {
setRateLimitHeaders(w, *httpErr.decision)
}
ev := logvr(v, r).Err(err)
if websocket.IsWebSocketUpgrade(r) {
ev.Tag(tagWebsocket).Fields(websocketErrorContext(err))
@@ -1038,7 +1041,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
bandwidthVisitor = s.visitor(m.Sender, nil)
}
if d := bandwidthVisitor.BandwidthAllowed(size); !d.OK {
return errHTTPTooManyRequestsLimitAttachmentBandwidth.With(m).RetryAfter(d.RetryAfter)
return errHTTPTooManyRequestsLimitAttachmentBandwidth.With(m).WithLimitDecision(d)
}
// Actually send file
if m.Attachment.Name != "" {
@@ -1095,7 +1098,7 @@ func (s *Server) dispatch(v *visitor, t *topic, m *model.Message, opts dispatchO
return nil
}
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Message, error) {
func (s *Server) handlePublishInternal(w http.ResponseWriter, r *http.Request, v *visitor) (*model.Message, error) {
if s.readOnly.Load() {
return nil, errHTTPServiceUnavailableDraining // Read-only; the load balancers stopped sending requests in the drain delay
}
@@ -1129,9 +1132,11 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
}
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
} else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
if d := vrate.MessageAllowed(); !d.OK {
return nil, errHTTPTooManyRequestsLimitMessages.With(t).RetryAfter(d.RetryAfter)
d := vrate.MessageAllowed()
if !d.OK {
return nil, errHTTPTooManyRequestsLimitMessages.With(t).WithLimitDecision(d)
}
setRateLimitHeaders(w, d)
}
if email != "" {
var httpErr *errHTTP
@@ -1139,7 +1144,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
if httpErr != nil {
return nil, httpErr.With(t)
} else if d := vrate.EmailAllowed(); !d.OK {
return nil, errHTTPTooManyRequestsLimitEmails.With(t).RetryAfter(d.RetryAfter)
return nil, errHTTPTooManyRequestsLimitEmails.With(t).WithLimitDecision(d)
}
}
if call != "" {
@@ -1148,7 +1153,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
if httpErr != nil {
return nil, httpErr.With(t)
} else if d := vrate.CallAllowed(); !d.OK {
return nil, errHTTPTooManyRequestsLimitCalls.With(t).RetryAfter(d.RetryAfter)
return nil, errHTTPTooManyRequestsLimitCalls.With(t).WithLimitDecision(d)
}
}
if m.PollID != "" {
@@ -1227,7 +1232,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
}
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
m, err := s.handlePublishInternal(r, v)
m, err := s.handlePublishInternal(w, r, v)
if err != nil {
metrics.MessagesPublishedFailure.Inc()
return err
@@ -1237,7 +1242,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
}
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
_, err := s.handlePublishInternal(r, v)
_, err := s.handlePublishInternal(w, r, v)
if err != nil {
metrics.MessagesPublishedFailure.Inc()
metrics.MatrixPublishedFailure.Inc()
@@ -1275,9 +1280,11 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
return err
}
if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
if d := vrate.MessageAllowed(); !d.OK {
return errHTTPTooManyRequestsLimitMessages.With(t).RetryAfter(d.RetryAfter)
d := vrate.MessageAllowed()
if !d.OK {
return errHTTPTooManyRequestsLimitMessages.With(t).WithLimitDecision(d)
}
setRateLimitHeaders(w, d)
}
sequenceID, e := s.sequenceIDFromPath(r.URL.Path)
if e != nil {
@@ -1719,7 +1726,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
// written.
if meterPollBandwidth {
if d := v.BandwidthAllowed(int64(len(encoded))); !d.OK {
return errHTTPTooManyRequestsLimitAttachmentBandwidth.RetryAfter(d.RetryAfter)
return errHTTPTooManyRequestsLimitAttachmentBandwidth.WithLimitDecision(d)
}
}
wlock.Lock()
@@ -2160,7 +2167,7 @@ func (s *Server) topicsFromIDs(v *visitor, ids ...string) ([]*topic, error) {
}
if v != nil {
if d := v.TopicCreationAllowed(); !d.OK {
return nil, errHTTPTooManyRequestsLimitTopicCreation.RetryAfter(d.RetryAfter)
return nil, errHTTPTooManyRequestsLimitTopicCreation.WithLimitDecision(d)
}
}
s.topics[id] = newTopic(id)
+4 -4
View File
@@ -31,7 +31,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
return errHTTPUnauthorized // Cannot create account from user context
}
if d := v.AccountActionAllowed(); !d.OK {
return errHTTPTooManyRequestsLimitAccountActions.RetryAfter(d.RetryAfter)
return errHTTPTooManyRequestsLimitAccountActions.WithLimitDecision(d)
}
}
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)
@@ -711,7 +711,7 @@ func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v
}
// Rate limit (counts against the user's email quota)
if d := v.EmailAllowed(); !d.OK {
return errHTTPTooManyRequestsLimitEmails.RetryAfter(d.RetryAfter)
return errHTTPTooManyRequestsLimitEmails.WithLimitDecision(d)
}
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Starting email verification")
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
@@ -804,7 +804,7 @@ func (s *Server) handleAccountEmailResend(w http.ResponseWriter, r *http.Request
return errHTTPBadRequestEmailAddressInvalid
}
if d := v.EmailAllowed(); !d.OK {
return errHTTPTooManyRequestsLimitEmails.RetryAfter(d.RetryAfter)
return errHTTPTooManyRequestsLimitEmails.WithLimitDecision(d)
}
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Resending email verification")
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
@@ -839,7 +839,7 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt
}
// Rate limit via the shared per-visitor account-creation bucket (no new limiter/config)
if d := v.AccountActionAllowed(); !d.OK {
return errHTTPTooManyRequestsLimitAccountActions.RetryAfter(d.RetryAfter)
return errHTTPTooManyRequestsLimitAccountActions.WithLimitDecision(d)
}
v.AccountActionPerformed() // Consume a token on every request (including no-match), to throttle probing
identifier := strings.TrimSpace(req.Identifier)
+1 -1
View File
@@ -39,7 +39,7 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*http.Request, *visitor, er
}
// If we're trying to auth, check the rate limiter first
if d := vip.AuthAllowed(); !d.OK {
return r, vip, errHTTPTooManyRequestsLimitAuthFailure.RetryAfter(d.RetryAfter) // Always return visitor, even when error occurs!
return r, vip, errHTTPTooManyRequestsLimitAuthFailure.WithLimitDecision(d) // Always return visitor, even when error occurs!
}
u, err := s.authenticate(r, header)
if err != nil {
+34 -3
View File
@@ -1,8 +1,11 @@
package server
import (
"fmt"
"math"
"net/http"
"heckel.io/ntfy/v2/limit"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
@@ -17,12 +20,18 @@ const (
contextAdminSocket // Set (to true) on requests that arrived over the admin socket, see isAdminSocketRequest
)
// Rate limit headers of draft-ietf-httpapi-ratelimit-headers-11, sent on publishes
const (
headerRateLimitPolicy = "RateLimit-Policy"
headerRateLimit = "RateLimit"
)
func (s *Server) limitRequests(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
return next(w, r, v)
} else if d := v.RequestAllowed(); !d.OK {
return errHTTPTooManyRequestsLimitRequests.RetryAfter(d.RetryAfter)
return errHTTPTooManyRequestsLimitRequests.WithLimitDecision(d)
}
return next(w, r, v)
}
@@ -48,13 +57,35 @@ func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
})
if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
return next(w, r, v)
} else if d := vrate.RequestAllowed(); !d.OK {
return errHTTPTooManyRequestsLimitRequests.RetryAfter(d.RetryAfter)
}
d := vrate.RequestAllowed()
if !d.OK {
return errHTTPTooManyRequestsLimitRequests.WithLimitDecision(d)
}
setRateLimitHeaders(w, d)
return next(w, r, v)
}
}
// setRateLimitHeaders adds the decision's limit as a policy named after its kind, e.g.
// RateLimit-Policy: "messages";q=250;w=86400 and RateLimit: "messages";r=249;t=3600. Each policy
// is its own field line, which the draft allows; a decision without a quota adds nothing.
func setRateLimitHeaders(w http.ResponseWriter, d limit.Decision) {
if d.Quota <= 0 {
return
}
policy := fmt.Sprintf(`"%s";q=%d`, d.Kind, d.Quota)
if d.Window > 0 {
policy += fmt.Sprintf(";w=%d", int64(math.Ceil(d.Window.Seconds())))
}
current := fmt.Sprintf(`"%s";r=%d`, d.Kind, d.Remaining)
if d.Reset > 0 {
current += fmt.Sprintf(";t=%d", int64(math.Ceil(d.Reset.Seconds()))) // Rounded up like Retry-After, so the two agree
}
w.Header().Add(headerRateLimitPolicy, policy)
w.Header().Add(headerRateLimit, current)
}
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.WebRoot == "" {
+90 -2
View File
@@ -32,6 +32,7 @@ import (
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/limit"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
@@ -5668,14 +5669,23 @@ func TestServer_HandleError_RetryAfter(t *testing.T) {
r, _ := http.NewRequest("GET", "/mytopic/json", nil)
v := newVisitor(s.visitors, netip.MustParseAddr("1.2.3.4"), nil)
rr := httptest.NewRecorder()
s.handleError(rr, r, v, errHTTPTooManyRequestsLimitRequests.RetryAfter(1500*time.Millisecond))
s.handleError(rr, r, v, errHTTPTooManyRequestsLimitRequests.WithLimitDecision(limit.Decision{
RetryAfter: 1500 * time.Millisecond,
Kind: limit.KindRequests,
Quota: 60,
Window: 5 * time.Minute,
Reset: 1500 * time.Millisecond,
}))
require.Equal(t, 429, rr.Code)
require.Equal(t, "2", rr.Header().Get("Retry-After"))
require.Equal(t, []string{`"requests";q=60;w=300`}, rr.Header().Values("RateLimit-Policy")) // The decision also names the limit
require.Equal(t, []string{`"requests";r=0;t=2`}, rr.Header().Values("RateLimit"))
require.Equal(t, 42901, toHTTPError(t, rr.Body.String()).Code)
rr = httptest.NewRecorder()
s.handleError(rr, r, v, errHTTPTooManyRequestsLimitRequests.RetryAfter(time.Millisecond))
s.handleError(rr, r, v, errHTTPTooManyRequestsLimitRequests.WithLimitDecision(limit.Decision{RetryAfter: time.Millisecond}))
require.Equal(t, "1", rr.Header().Get("Retry-After"))
require.Empty(t, rr.Header().Values("RateLimit")) // No quota, no policy
rr = httptest.NewRecorder()
s.handleError(rr, r, v, errHTTPTooManyRequestsLimitSubscriptions)
@@ -5775,6 +5785,84 @@ func TestServer_RetryAfter_TopicCreationLimit(t *testing.T) {
})
}
func TestServer_RateLimitHeaders_Publish(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// Each publish carries the request bucket and the daily message limit, and the 429 of
// the daily limit says 0 remaining until the same time as Retry-After
c := newTestConfig(t, databaseURL)
c.VisitorRequestLimitBurst = 60
c.VisitorRequestLimitReplenish = 5 * time.Second
c.VisitorMessageDailyLimit = 3
s := newTestServer(t, c)
resetAt := s.limiter.ResetAt(s.visitor(netip.MustParseAddr("9.9.9.9"), nil).key)
for i := 0; i < 3; i++ {
rr := request(t, s, "PUT", "/mytopic", "hi", nil)
require.Equal(t, 200, rr.Code)
require.Equal(t, []string{`"requests";q=60;w=300`, `"messages";q=3;w=86400`}, rr.Header().Values("RateLimit-Policy"))
limits := rr.Header().Values("RateLimit")
require.Len(t, limits, 2)
require.Equal(t, fmt.Sprintf(`"requests";r=%d;t=5`, 59-i), limits[0])
r, reset := parseRateLimit(t, limits[1], "messages")
require.Equal(t, 2-i, r)
require.InDelta(t, time.Until(resetAt).Seconds(), reset, 2)
}
rr := request(t, s, "PUT", "/mytopic", "hi", nil)
require.Equal(t, 429, rr.Code)
require.Equal(t, []string{`"requests";q=60;w=300`, `"messages";q=3;w=86400`}, rr.Header().Values("RateLimit-Policy"))
require.Equal(t, `"requests";r=56;t=5`, rr.Header().Values("RateLimit")[0])
require.Equal(t, fmt.Sprintf(`"messages";r=0;t=%d`, retryAfterSeconds(t, rr)), rr.Header().Values("RateLimit")[1])
})
}
func TestServer_RateLimitHeaders_RequestLimit(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// The 429 of the request bucket says 0 remaining until the next token, as Retry-After does
c := newTestConfig(t, databaseURL)
c.VisitorRequestLimitBurst = 3
c.VisitorRequestLimitReplenish = 5 * time.Second
s := newTestServer(t, c)
for i := 0; i < 3; i++ {
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", "hi", nil).Code)
}
rr := request(t, s, "PUT", "/mytopic", "hi", nil)
require.Equal(t, 429, rr.Code)
require.Equal(t, []string{`"requests";q=3;w=15`}, rr.Header().Values("RateLimit-Policy")) // The handler, and the daily limit, are never reached
require.Equal(t, []string{fmt.Sprintf(`"requests";r=0;t=%d`, retryAfterSeconds(t, rr))}, rr.Header().Values("RateLimit"))
})
}
func TestServer_RateLimitHeaders_Exempt(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfig(t, databaseURL)
c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
s := newTestServer(t, c)
rr := request(t, s, "PUT", "/mytopic", "hi", nil)
require.Equal(t, 200, rr.Code)
require.Empty(t, rr.Header().Values("RateLimit-Policy"))
require.Empty(t, rr.Header().Values("RateLimit"))
})
}
func TestServer_RateLimitHeaders_NotOnPoll(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", "hi", nil).Code)
rr := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, rr.Code)
require.Empty(t, rr.Header().Values("RateLimit-Policy"))
require.Empty(t, rr.Header().Values("RateLimit"))
})
}
// parseRateLimit parses one RateLimit item, e.g. "messages";r=2;t=3600, into r and t
func parseRateLimit(t *testing.T, item, name string) (int, float64) {
t.Helper()
var r, reset int
_, err := fmt.Sscanf(item, `"`+name+`";r=%d;t=%d`, &r, &reset)
require.NoError(t, err, "invalid RateLimit item: %s", item)
return r, float64(reset)
}
func retryAfterSeconds(t *testing.T, rr *httptest.ResponseRecorder) int {
t.Helper()
header := rr.Header().Get("Retry-After")