From cc67c2b31ced0e4c35139150ba738507aef2e0af Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Nov 2026 13:50:00 +0000 Subject: [PATCH] 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. --- docs/config.md | 2 +- limit/service.go | 14 +++ limit/service_test.go | 13 +- limit/types.go | 1 + server/errors.go | 243 ++++++++++++++++++------------------ server/server.go | 21 ++-- server/server_account.go | 8 +- server/server_auth.go | 2 +- server/server_middleware.go | 14 +-- server/server_test.go | 14 ++- 10 files changed, 182 insertions(+), 150 deletions(-) diff --git a/docs/config.md b/docs/config.md index 8ff10fff..b9d9bcfd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 diff --git a/limit/service.go b/limit/service.go index 3f895a20..2511cee7 100644 --- a/limit/service.go +++ b/limit/service.go @@ -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{} } diff --git a/limit/service_test.go b/limit/service_test.go index 6716afd5..b1d31b52 100644 --- a/limit/service_test.go +++ b/limit/service_test.go @@ -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) { diff --git a/limit/types.go b/limit/types.go index 194c19e8..84a6903c 100644 --- a/limit/types.go +++ b/limit/types.go @@ -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 diff --git a/server/errors.go b/server/errors.go index 2a1f22f1..d878e530 100644 --- a/server/errors.go +++ b/server/errors.go @@ -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} ) diff --git a/server/server.go b/server/server.go index a3011a1f..0545d38a 100644 --- a/server/server.go +++ b/server/server.go @@ -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) diff --git a/server/server_account.go b/server/server_account.go index 3c5bf83b..3db7d30a 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -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) diff --git a/server/server_auth.go b/server/server_auth.go index 0a426310..927b568e 100644 --- a/server/server_auth.go +++ b/server/server_auth.go @@ -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 { diff --git a/server/server_middleware.go b/server/server_middleware.go index 7b72a9d7..0adcefdf 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -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 } diff --git a/server/server_test.go b/server/server_test.go index a6d306f7..7780103c 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -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)