mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-11-10 14:44:04 +08:00
Carry the whole limit decision on a 429 instead of only its wait
errHTTP.WithLimitDecision replaces RetryAfter: handleError writes Retry-After and the RateLimit headers from it, and the decision names its own kind. A request that is allowed gets its RateLimit headers right away; a denied one gets them with the error.
This commit is contained in:
1 parent
7c112e2386
commit
cc67c2b31c
10 files changed
+182
-150
No files matched your search
+1
-1
@@ -1929,7 +1929,7 @@ 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 (including the 429s) also carry the `RateLimit-Policy` and `RateLimit` headers of the IETF draft
|
||||
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
|
||||
|
||||
@@ -103,6 +103,13 @@ func newService(conf *Config, 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()
|
||||
d := s.takeTokens(key, &rule, n, now)
|
||||
if !d.OK {
|
||||
@@ -211,6 +218,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{}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ func TestService_Allow_QuotaBucket(t *testing.T) {
|
||||
}
|
||||
expected := Decision{
|
||||
OK: true,
|
||||
Kind: KindRequests,
|
||||
Quota: 3,
|
||||
Window: 300 * time.Millisecond,
|
||||
Remaining: 2,
|
||||
@@ -139,6 +140,7 @@ func TestService_Allow_QuotaBucket(t *testing.T) {
|
||||
clock.advance(40 * time.Millisecond)
|
||||
expected = Decision{
|
||||
RetryAfter: 60 * time.Millisecond,
|
||||
Kind: KindRequests,
|
||||
Quota: 3,
|
||||
Window: 300 * time.Millisecond,
|
||||
Remaining: 0,
|
||||
@@ -157,6 +159,7 @@ func TestService_Allow_QuotaDaily(t *testing.T) {
|
||||
reset := 15*time.Hour + 48*time.Minute + 26*time.Second
|
||||
expected := Decision{
|
||||
OK: true,
|
||||
Kind: KindMessages,
|
||||
Quota: 2,
|
||||
Window: 24 * time.Hour,
|
||||
Remaining: 1,
|
||||
@@ -332,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) {
|
||||
|
||||
@@ -47,6 +47,7 @@ type Rule struct {
|
||||
type Decision struct {
|
||||
OK bool
|
||||
RetryAfter time.Duration
|
||||
Kind Kind // The rule's kind, which names the limit in the RateLimit headers
|
||||
Quota int64 // The daily cap or the burst; 0 if the rule has neither
|
||||
Window time.Duration // A day, or the time the bucket takes to refill completely
|
||||
Remaining int64 // Units left after this call
|
||||
|
||||
+122
-121
@@ -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,107 +116,107 @@ 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}
|
||||
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}
|
||||
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}
|
||||
)
|
||||
+12
-9
@@ -704,6 +704,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))
|
||||
@@ -1023,7 +1026,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 != "" {
|
||||
@@ -1110,10 +1113,10 @@ func (s *Server) handlePublishInternal(w http.ResponseWriter, r *http.Request, v
|
||||
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
|
||||
} else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
|
||||
d := vrate.MessageAllowed()
|
||||
setRateLimitHeaders(w, limit.KindMessages, d)
|
||||
if !d.OK {
|
||||
return nil, errHTTPTooManyRequestsLimitMessages.With(t).RetryAfter(d.RetryAfter)
|
||||
return nil, errHTTPTooManyRequestsLimitMessages.With(t).WithLimitDecision(d)
|
||||
}
|
||||
setRateLimitHeaders(w, d)
|
||||
}
|
||||
if email != "" {
|
||||
var httpErr *errHTTP
|
||||
@@ -1121,7 +1124,7 @@ func (s *Server) handlePublishInternal(w http.ResponseWriter, r *http.Request, v
|
||||
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 != "" {
|
||||
@@ -1130,7 +1133,7 @@ func (s *Server) handlePublishInternal(w http.ResponseWriter, r *http.Request, v
|
||||
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 != "" {
|
||||
@@ -1262,10 +1265,10 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) {
|
||||
d := vrate.MessageAllowed()
|
||||
setRateLimitHeaders(w, limit.KindMessages, d)
|
||||
if !d.OK {
|
||||
return errHTTPTooManyRequestsLimitMessages.With(t).RetryAfter(d.RetryAfter)
|
||||
return errHTTPTooManyRequestsLimitMessages.With(t).WithLimitDecision(d)
|
||||
}
|
||||
setRateLimitHeaders(w, d)
|
||||
}
|
||||
sequenceID, e := s.sequenceIDFromPath(r.URL.Path)
|
||||
if e != nil {
|
||||
@@ -1707,7 +1710,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()
|
||||
@@ -2152,7 +2155,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 {
|
||||
|
||||
@@ -31,7 +31,7 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
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)
|
||||
}
|
||||
@@ -56,26 +56,26 @@ func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
|
||||
return next(w, r, v)
|
||||
}
|
||||
d := vrate.RequestAllowed()
|
||||
setRateLimitHeaders(w, limit.KindRequests, d)
|
||||
if !d.OK {
|
||||
return errHTTPTooManyRequestsLimitRequests.RetryAfter(d.RetryAfter)
|
||||
return errHTTPTooManyRequestsLimitRequests.WithLimitDecision(d)
|
||||
}
|
||||
setRateLimitHeaders(w, d)
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
// setRateLimitHeaders adds the decision's limit as a policy named after the kind, e.g.
|
||||
// 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, kind limit.Kind, d limit.Decision) {
|
||||
func setRateLimitHeaders(w http.ResponseWriter, d limit.Decision) {
|
||||
if d.Quota <= 0 {
|
||||
return
|
||||
}
|
||||
policy := fmt.Sprintf(`"%s";q=%d`, kind, d.Quota)
|
||||
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`, kind, d.Remaining)
|
||||
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
|
||||
}
|
||||
|
||||
+12
-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"
|
||||
@@ -5685,14 +5686,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)
|
||||
|
||||
Reference in new issue
Block a user