mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-11-10 14:44:04 +08:00
Merge remote-tracking branch 'origin/main' into up-payer
# Conflicts: # server/errors.go
This commit is contained in:
commit
5bd36bdcd9
11 files changed
+402
-159
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user