From 5306dfaa97e0de259ebd3f062080d8236c60da18 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 1 Nov 2026 11:47:56 +0000 Subject: [PATCH] Send a Retry-After header with rate limit 429 responses --- server/errors.go | 247 ++++++++++++++++++---------------- server/server.go | 39 ++++-- server/server_account.go | 16 +-- server/server_account_test.go | 3 + server/server_auth.go | 4 +- server/server_middleware.go | 8 +- server/server_test.go | 126 ++++++++++++++++- server/visitor.go | 55 ++++---- server/visitor_test.go | 94 ++++++------- 9 files changed, 376 insertions(+), 216 deletions(-) diff --git a/server/errors.go b/server/errors.go index 79bc8cbd..2a1f22f1 100644 --- a/server/errors.go +++ b/server/errors.go @@ -3,18 +3,22 @@ package server import ( "encoding/json" "fmt" + "math" "net/http" + "strconv" + "time" "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 + 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 } func (e errHTTP) Error() string { @@ -64,20 +68,37 @@ 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 { + c := e.clone() + c.retryAfter = d + return &c +} + func (e errHTTP) clone() errHTTP { context := make(log.Context) for k, v := range e.context { context[k] = v } return errHTTP{ - Code: e.Code, - HTTPCode: e.HTTPCode, - Message: e.Message, - Link: e.Link, - context: context, + Code: e.Code, + HTTPCode: e.HTTPCode, + Message: e.Message, + Link: e.Link, + context: context, + retryAfter: e.retryAfter, } } +// 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 { + return "" + } + return strconv.FormatInt(int64(math.Ceil(e.retryAfter.Seconds())), 10) +} + // errWebSocketPostUpgrade is a wrapper error indicating an error occurred after the WebSocket // upgrade completed (i.e., the connection was hijacked). This is used to avoid calling // WriteHeader on hijacked connections, which causes log spam. @@ -94,107 +115,107 @@ func (e *errWebSocketPostUpgrade) Unwrap() error { } var ( - errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil} - errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil} - errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", "", nil} - errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", "", nil} - errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} - errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} - errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} - errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority", nil} - errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil} - errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil} - errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil} - errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid request: message must be UTF-8 encoded", "", nil} - errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil} - errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil} - errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} - errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets", nil} - errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json", nil} - errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons", nil} - errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway", nil} - errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons", nil} - errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config", nil} - errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", "", nil} - errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", "", nil} - errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", "", nil} - errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", "", nil} - errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil} - errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil} - errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil} - errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil} - errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil} - errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil} - errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", 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} - errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil} - errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil} - errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "invalid request: delayed call notifications are not supported", "", nil} - errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil} - errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil} - errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", 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} - errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", 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} - errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestTemplateExecutionTimeout = &errHTTP{40055, http.StatusBadRequest, "invalid request: template execution timed out", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} - errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil} - errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} - errHTTPBadRequestEmailVerificationLinkInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil} - errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} - errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} - errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil} - errHTTPBadRequestTemplateTooLarge = &errHTTP{40056, http.StatusBadRequest, "invalid request: template too large", "https://ntfy.sh/docs/publish/#message-templating", nil} - errHTTPBadRequestTitleTooLarge = &errHTTP{40057, http.StatusBadRequest, "invalid request: title is too large", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPBadRequestTagsTooLarge = &errHTTP{40058, http.StatusBadRequest, "invalid request: tags are too large", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPBadRequestTimezoneInvalid = &errHTTP{40059, http.StatusBadRequest, "invalid request: invalid timezone", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil} - errHTTPBadRequestPhoneVerificationCodeInvalid = &errHTTP{40060, http.StatusBadRequest, "invalid request: phone number verification code invalid", "", nil} - errHTTPBadRequestTooManyTopics = &errHTTP{40061, http.StatusBadRequest, "invalid request: too many topics", "https://ntfy.sh/docs/subscribe/api/#subscribe-to-multiple-topics", nil} - errHTTPBadRequestTierCodeInvalid = &errHTTP{40062, http.StatusBadRequest, "invalid request: tier code invalid, or tier name missing", "", nil} - errHTTPBadRequestTierReservationsLimit = &errHTTP{40063, http.StatusBadRequest, "invalid request: user has more topic reservations than the tier allows, remove reservations first", "", nil} - errHTTPBadRequestAccessAdminUser = &errHTTP{40064, http.StatusBadRequest, "invalid request: admins can access all topics, access control entries are not allowed", "", nil} - errHTTPBadRequestTokenNotFound = &errHTTP{40065, http.StatusBadRequest, "invalid request: token does not exist", "", nil} - errHTTPBadRequestRoleInvalid = &errHTTP{40066, http.StatusBadRequest, "invalid request: role must be user or admin", "", nil} - errHTTPBadRequestUserNoPrimaryEmail = &errHTTP{40067, http.StatusBadRequest, "invalid request: user has no primary email, cannot send reset link", "", 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} - 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} - errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} - errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} - errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", 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} - errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil} - errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil} - errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil} - errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil} - errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil} - errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil} - errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil} - errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the primary email on another account", "", nil} - errHTTPConflictTierExists = &errHTTP{40909, http.StatusConflict, "conflict: tier already exists", "", nil} - errHTTPConflictTierInUse = &errHTTP{40910, http.StatusConflict, "conflict: tier is still assigned to users", "", nil} - errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil} - errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil} - errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil} - errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", 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} - errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit - errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil} - errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit - errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitTopicCreation = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: too many new topics, please wait", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil} - errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil} - errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil} - errHTTPInternalErrorWebPushUnableToPublish = &errHTTP{50004, http.StatusInternalServerError, "internal server error: unable to publish web push message", "", nil} - errHTTPServiceUnavailableDraining = &errHTTP{50301, http.StatusServiceUnavailable, "service unavailable: this node is shutting down, please reconnect", "", nil} - errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil} + 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} ) diff --git a/server/server.go b/server/server.go index b20600b5..5ac77430 100644 --- a/server/server.go +++ b/server/server.go @@ -701,6 +701,9 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, isRateLimiting := util.Contains(rateLimitingErrorCodes, httpErr.HTTPCode) isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains(normalErrorCodes, httpErr.HTTPCode) || httpErr.Code == errHTTPServiceUnavailableDraining.Code // A draining node rejects every new subscription, by design + if retryAfter := httpErr.retryAfterSeconds(); retryAfter != "" { + w.Header().Set("Retry-After", retryAfter) + } ev := logvr(v, r).Err(err) if websocket.IsWebSocketUpgrade(r) { ev.Tag(tagWebsocket).Fields(websocketErrorContext(err)) @@ -1019,8 +1022,8 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) } else if m.Sender.IsValid() { bandwidthVisitor = s.visitor(m.Sender, nil) } - if !bandwidthVisitor.BandwidthAllowed(size) { - return errHTTPTooManyRequestsLimitAttachmentBandwidth.With(m) + if d := bandwidthVisitor.BandwidthAllowed(size); !d.OK { + return errHTTPTooManyRequestsLimitAttachmentBandwidth.With(m).RetryAfter(d.RetryAfter) } // Actually send file if m.Attachment.Name != "" { @@ -1105,16 +1108,18 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess // the subscription as invalid if any 400-499 code (except 429/408) is returned. // See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46 return nil, errHTTPInsufficientStorageUnifiedPush.With(t) - } else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { - return nil, errHTTPTooManyRequestsLimitMessages.With(t) + } else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) { + if d := vrate.MessageAllowed(); !d.OK { + return nil, errHTTPTooManyRequestsLimitMessages.With(t).RetryAfter(d.RetryAfter) + } } if email != "" { var httpErr *errHTTP email, httpErr = s.convertEmailAddress(v.User(), email) if httpErr != nil { return nil, httpErr.With(t) - } else if !vrate.EmailAllowed() { - return nil, errHTTPTooManyRequestsLimitEmails.With(t) + } else if d := vrate.EmailAllowed(); !d.OK { + return nil, errHTTPTooManyRequestsLimitEmails.With(t).RetryAfter(d.RetryAfter) } } if call != "" { @@ -1122,8 +1127,8 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess call, httpErr = s.convertPhoneNumber(v.User(), call) if httpErr != nil { return nil, httpErr.With(t) - } else if !vrate.CallAllowed() { - return nil, errHTTPTooManyRequestsLimitCalls.With(t) + } else if d := vrate.CallAllowed(); !d.OK { + return nil, errHTTPTooManyRequestsLimitCalls.With(t).RetryAfter(d.RetryAfter) } } if m.PollID != "" { @@ -1253,8 +1258,10 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * if err != nil { return err } - if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { - return errHTTPTooManyRequestsLimitMessages.With(t) + if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) { + if d := vrate.MessageAllowed(); !d.OK { + return errHTTPTooManyRequestsLimitMessages.With(t).RetryAfter(d.RetryAfter) + } } sequenceID, e := s.sequenceIDFromPath(r.URL.Path) if e != nil { @@ -1694,8 +1701,10 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * // Charge the encoded length, i.e. what actually goes over the wire. Charge before writing, // so an exhausted budget fails the first message and surfaces as a clean 429 with nothing // written. - if meterPollBandwidth && !v.BandwidthAllowed(int64(len(encoded))) { - return errHTTPTooManyRequestsLimitAttachmentBandwidth + if meterPollBandwidth { + if d := v.BandwidthAllowed(int64(len(encoded))); !d.OK { + return errHTTPTooManyRequestsLimitAttachmentBandwidth.RetryAfter(d.RetryAfter) + } } wlock.Lock() defer wlock.Unlock() @@ -2137,8 +2146,10 @@ func (s *Server) topicsFromIDs(v *visitor, ids ...string) ([]*topic, error) { if len(s.topics) >= s.config.TotalTopicLimit { return nil, errHTTPTooManyRequestsLimitTotalTopics } - if v != nil && !v.TopicCreationAllowed() { - return nil, errHTTPTooManyRequestsLimitTopicCreation + if v != nil { + if d := v.TopicCreationAllowed(); !d.OK { + return nil, errHTTPTooManyRequestsLimitTopicCreation.RetryAfter(d.RetryAfter) + } } s.topics[id] = newTopic(id) } diff --git a/server/server_account.go b/server/server_account.go index 61616cc3..3c5bf83b 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -30,8 +30,8 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * } else if u != nil { return errHTTPUnauthorized // Cannot create account from user context } - if !v.AccountActionAllowed() { - return errHTTPTooManyRequestsLimitAccountActions + if d := v.AccountActionAllowed(); !d.OK { + return errHTTPTooManyRequestsLimitAccountActions.RetryAfter(d.RetryAfter) } } newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false) @@ -710,8 +710,8 @@ func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v return errHTTPConflictEmailExists } // Rate limit (counts against the user's email quota) - if !v.EmailAllowed() { - return errHTTPTooManyRequestsLimitEmails + if d := v.EmailAllowed(); !d.OK { + return errHTTPTooManyRequestsLimitEmails.RetryAfter(d.RetryAfter) } logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Starting email verification") if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil { @@ -803,8 +803,8 @@ func (s *Server) handleAccountEmailResend(w http.ResponseWriter, r *http.Request } else if !util.Contains(pending, req.Email) { return errHTTPBadRequestEmailAddressInvalid } - if !v.EmailAllowed() { - return errHTTPTooManyRequestsLimitEmails + if d := v.EmailAllowed(); !d.OK { + return errHTTPTooManyRequestsLimitEmails.RetryAfter(d.RetryAfter) } logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Resending email verification") if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil { @@ -838,8 +838,8 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt return err } // Rate limit via the shared per-visitor account-creation bucket (no new limiter/config) - if !v.AccountActionAllowed() { - return errHTTPTooManyRequestsLimitAccountActions + if d := v.AccountActionAllowed(); !d.OK { + return errHTTPTooManyRequestsLimitAccountActions.RetryAfter(d.RetryAfter) } v.AccountActionPerformed() // Consume a token on every request (including no-match), to throttle probing identifier := strings.TrimSpace(req.Identifier) diff --git a/server/server_account_test.go b/server/server_account_test.go index 39528166..ea17f915 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -142,6 +142,9 @@ func TestAccount_Signup_LimitReached(t *testing.T) { rr := request(t, s, "POST", "/v1/account", `{"username":"thiswontwork", "password":"mypass"}`, nil) require.Equal(t, 429, rr.Code) require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code) + retryAfter := retryAfterSeconds(t, rr) // One token refills per DefaultVisitorAccountCreationLimitReplenish + require.Greater(t, retryAfter, int(DefaultVisitorAccountCreationLimitReplenish.Seconds())-60) + require.LessOrEqual(t, retryAfter, int(DefaultVisitorAccountCreationLimitReplenish.Seconds())) }) } diff --git a/server/server_auth.go b/server/server_auth.go index 6e9dc589..0a426310 100644 --- a/server/server_auth.go +++ b/server/server_auth.go @@ -38,8 +38,8 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*http.Request, *visitor, er return r, vip, nil } // If we're trying to auth, check the rate limiter first - if !vip.AuthAllowed() { - return r, vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs! + if d := vip.AuthAllowed(); !d.OK { + return r, vip, errHTTPTooManyRequestsLimitAuthFailure.RetryAfter(d.RetryAfter) // 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 4f41e264..d454ccc8 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -21,8 +21,8 @@ func (s *Server) limitRequests(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) { return next(w, r, v) - } else if !v.RequestAllowed() { - return errHTTPTooManyRequestsLimitRequests + } else if d := v.RequestAllowed(); !d.OK { + return errHTTPTooManyRequestsLimitRequests.RetryAfter(d.RetryAfter) } return next(w, r, v) } @@ -45,8 +45,8 @@ func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc { }) if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) { return next(w, r, v) - } else if !vrate.RequestAllowed() { - return errHTTPTooManyRequestsLimitRequests + } else if d := vrate.RequestAllowed(); !d.OK { + return errHTTPTooManyRequestsLimitRequests.RetryAfter(d.RetryAfter) } return next(w, r, v) } diff --git a/server/server_test.go b/server/server_test.go index cb901196..c0e50a53 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3619,7 +3619,7 @@ func TestServer_SubscriberRateLimiting_StreamDoesNotReclaimTopicAfterWriteRevoke return to != nil && to.SubscribersCount() == 1 }) to := lockedTopic(s, "upAAAAAAAAAAAA") - require.True(t, to.RateVisitor().MessageAllowed()) + require.True(t, to.RateVisitor().MessageAllowed().OK) require.Nil(t, s.userManager.AllowAccess("alice", "upAAAAAAAAAAAA", user.PermissionRead)) // Bob takes the topic with a poll, and is gone a day later @@ -5677,6 +5677,130 @@ func TestServer_HandleError_SkipsWriteHeaderOnHijackedConnection(t *testing.T) { }) } +func TestServer_HandleError_RetryAfter(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + // A 429 with a known wait carries Retry-After in whole seconds, rounded up; one without + // (e.g. the subscription gauge) carries none + s := newTestServer(t, newTestConfig(t, databaseURL)) + 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)) + require.Equal(t, 429, rr.Code) + require.Equal(t, "2", rr.Header().Get("Retry-After")) + require.Equal(t, 42901, toHTTPError(t, rr.Body.String()).Code) + + rr = httptest.NewRecorder() + s.handleError(rr, r, v, errHTTPTooManyRequestsLimitRequests.RetryAfter(time.Millisecond)) + require.Equal(t, "1", rr.Header().Get("Retry-After")) + + rr = httptest.NewRecorder() + s.handleError(rr, r, v, errHTTPTooManyRequestsLimitSubscriptions) + require.Equal(t, 429, rr.Code) + require.Empty(t, rr.Header().Get("Retry-After")) + }) +} + +func TestServer_RetryAfter_RequestLimit(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) + c.VisitorRequestLimitBurst = 3 + c.VisitorRequestLimitReplenish = 5 * time.Second + s := newTestServer(t, c) + for i := 0; i < 3; i++ { + require.Equal(t, 200, request(t, s, "PUT", "/mytopic", "hi", nil).Code) + } + rr := request(t, s, "PUT", "/mytopic", "hi", nil) + require.Equal(t, 429, rr.Code) + require.Equal(t, 42901, toHTTPError(t, rr.Body.String()).Code) + retryAfter := retryAfterSeconds(t, rr) + require.GreaterOrEqual(t, retryAfter, 1) + require.LessOrEqual(t, retryAfter, 5) + }) +} + +func TestServer_RetryAfter_DailyMessageLimit(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) + c.VisitorMessageDailyLimit = 2 + s := newTestServer(t, c) + for i := 0; i < 2; i++ { + require.Equal(t, 200, request(t, s, "PUT", "/mytopic", "hi", nil).Code) + } + rr := request(t, s, "PUT", "/mytopic", "hi", nil) + require.Equal(t, 429, rr.Code) + require.Equal(t, 42908, toHTTPError(t, rr.Body.String()).Code) + resetAt := s.limiter.ResetAt(s.visitor(netip.MustParseAddr("9.9.9.9"), nil).key) + require.InDelta(t, time.Until(resetAt).Seconds(), retryAfterSeconds(t, rr), 2) // The wait is until the key's period ends + }) +} + +func TestServer_RetryAfter_EmailLimit(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) + c.VisitorEmailLimitBurst = 1 + c.VisitorEmailLimitReplenish = time.Hour + s := newTestServer(t, c) + s.mailer = &testMailer{} + headers := map[string]string{ + "E-Mail": "test@example.com", + } + require.Equal(t, 200, request(t, s, "PUT", "/mytopic", "hi", headers).Code) + rr := request(t, s, "PUT", "/mytopic", "hi", headers) + require.Equal(t, 429, rr.Code) + require.Equal(t, 42902, toHTTPError(t, rr.Body.String()).Code) + retryAfter := retryAfterSeconds(t, rr) + require.Greater(t, retryAfter, 3500) + require.LessOrEqual(t, retryAfter, 3600) + }) +} + +func TestServer_RetryAfter_AuthFailureLimit(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfigWithAuthFile(t, databaseURL) + c.VisitorAuthFailureLimitBurst = 2 + c.VisitorAuthFailureLimitReplenish = time.Minute + s := newTestServer(t, c) + headers := map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + } + for i := 0; i < 2; i++ { + require.Equal(t, 401, request(t, s, "PUT", "/announcements", "test", headers).Code) + } + rr := request(t, s, "PUT", "/announcements", "test", headers) + require.Equal(t, 429, rr.Code) + require.Equal(t, 42909, toHTTPError(t, rr.Body.String()).Code) + retryAfter := retryAfterSeconds(t, rr) + require.Greater(t, retryAfter, 50) + require.LessOrEqual(t, retryAfter, 60) + }) +} + +func TestServer_RetryAfter_TopicCreationLimit(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) + c.VisitorTopicCreationLimitBurst = 1 + c.VisitorTopicCreationLimitReplenish = time.Minute + s := newTestServer(t, c) + require.Equal(t, 200, request(t, s, "PUT", "/fresh-topic-1", "hi", nil).Code) + rr := request(t, s, "PUT", "/fresh-topic-2", "hi", nil) + require.Equal(t, 429, rr.Code) + require.Equal(t, 42911, toHTTPError(t, rr.Body.String()).Code) + retryAfter := retryAfterSeconds(t, rr) + require.Greater(t, retryAfter, 50) + require.LessOrEqual(t, retryAfter, 60) + }) +} + +func retryAfterSeconds(t *testing.T, rr *httptest.ResponseRecorder) int { + t.Helper() + header := rr.Header().Get("Retry-After") + require.NotEmpty(t, header, "Retry-After header missing") + seconds, err := strconv.Atoi(header) + require.NoError(t, err) + return seconds +} + func TestServer_Publish_InvalidUTF8InBody(t *testing.T) { // All byte sequences from production logs, sent as message body tests := []struct { diff --git a/server/visitor.go b/server/visitor.go index fdfa58bb..728c7794 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -166,11 +166,12 @@ func visitorExtendedInfoContext(info *visitorInfo) log.Context { } -func (v *visitor) RequestAllowed() bool { +// RequestAllowed takes a request from the visitor's bucket, if it has one; a denial says how long to wait +func (v *visitor) RequestAllowed() limit.Decision { if v.limits.RequestLimitBurst <= 0 { - return false // A bucket of nothing admits nothing, as before + return limit.Decision{} // A bucket of nothing admits nothing, as before } - return v.env.limiter.Allow(v.key, v.requestRule(), 1).OK + return v.env.limiter.Allow(v.key, v.requestRule(), 1) } // FirebaseAllowed returns false while Firebase sends are paused for the key (see FirebaseTemporarilyDeny) @@ -191,27 +192,27 @@ func (v *visitor) FirebaseTemporarilyDeny() { v.env.limiter.Allow(v.key, rule, 1) } -func (v *visitor) MessageAllowed() bool { +func (v *visitor) MessageAllowed() limit.Decision { if v.limits.MessageLimit <= 0 { - return false // A daily limit of zero admits nothing; the limit service reads it as "no cap" + return limit.Decision{} // A daily limit of zero admits nothing; the limit service reads it as "no cap" } - return v.env.limiter.Allow(v.key, v.messageRule(), 1).OK + return v.env.limiter.Allow(v.key, v.messageRule(), 1) } -func (v *visitor) EmailAllowed() bool { +func (v *visitor) EmailAllowed() limit.Decision { if v.limits.EmailLimitBurst <= 0 { - return false // No email bucket + return limit.Decision{} // No email bucket } else if v.limits.Basis == vis.BasisTier && v.limits.EmailLimit <= 0 { - return false // No emails on the tier: the limit service reads a daily limit of zero as "no cap" + return limit.Decision{} // No emails on the tier: the limit service reads a daily limit of zero as "no cap" } - return v.env.limiter.Allow(v.key, v.emailRule(), 1).OK + return v.env.limiter.Allow(v.key, v.emailRule(), 1) } -func (v *visitor) CallAllowed() bool { +func (v *visitor) CallAllowed() limit.Decision { if v.limits.CallLimit <= 0 { - return false // A daily limit of zero admits nothing (e.g. a tier without calls, verified number or not) + return limit.Decision{} // A daily limit of zero admits nothing (e.g. a tier without calls, verified number or not) } - return v.env.limiter.Allow(v.key, v.callRule(), 1).OK + return v.env.limiter.Allow(v.key, v.callRule(), 1) } // SubscriptionAllowed takes one of the visitor key's subscription slots on this node, if one is free. @@ -220,18 +221,18 @@ func (v *visitor) SubscriptionAllowed() bool { return v.env.subscriptions.Acquire(v.key, int64(v.env.config.VisitorSubscriptionLimit)) } -// TopicCreationAllowed returns true if the visitor is allowed to cause a new topic to be -// inserted into the server's in-memory topic map. Returns true if no limiter is configured. -func (v *visitor) TopicCreationAllowed() bool { +// TopicCreationAllowed returns OK if the visitor is allowed to cause a new topic to be +// inserted into the server's in-memory topic map. Returns OK if no limiter is configured. +func (v *visitor) TopicCreationAllowed() limit.Decision { if !v.topicCreationLimited() { - return true + return limit.Decision{OK: true} } - return v.env.limiter.Allow(v.key, v.topicCreationRule(), 1).OK + return v.env.limiter.Allow(v.key, v.topicCreationRule(), 1) } -// AuthAllowed returns true if an auth request can be attempted (a token is available) -func (v *visitor) AuthAllowed() bool { - return v.env.limiter.Tokens(v.key, v.authRule()) >= 1 +// AuthAllowed returns OK if an auth request can be attempted (a token is available) +func (v *visitor) AuthAllowed() limit.Decision { + return v.env.limiter.Check(v.key, v.authRule()) } // AuthFailed records an auth failure @@ -239,10 +240,10 @@ func (v *visitor) AuthFailed() { v.env.limiter.Allow(v.key, v.authRule(), 1) } -// AccountActionAllowed returns true if a rate-limited account action (signup or password-reset +// AccountActionAllowed returns OK if a rate-limited account action (signup or password-reset // request) is currently allowed for this visitor -func (v *visitor) AccountActionAllowed() bool { - return v.env.limiter.Tokens(v.key, v.accountRule()) >= 1 +func (v *visitor) AccountActionAllowed() limit.Decision { + return v.env.limiter.Check(v.key, v.accountRule()) } // AccountActionPerformed decreases the account limiter. This is to be called after a rate-limited @@ -251,11 +252,11 @@ func (v *visitor) AccountActionPerformed() { v.env.limiter.Allow(v.key, v.accountRule(), 1) } -func (v *visitor) BandwidthAllowed(bytes int64) bool { +func (v *visitor) BandwidthAllowed(bytes int64) limit.Decision { if v.limits.AttachmentBandwidthLimit <= 0 { - return false // A bandwidth limit of zero admits nothing, as before + return limit.Decision{} // A bandwidth limit of zero admits nothing, as before } - return v.env.limiter.Allow(v.key, v.bandwidthRule(), bytes).OK + return v.env.limiter.Allow(v.key, v.bandwidthRule(), bytes) } // RemoveSubscription gives back a slot taken by SubscriptionAllowed diff --git a/server/visitor_test.go b/server/visitor_test.go index a89a6c4b..11d35748 100644 --- a/server/visitor_test.go +++ b/server/visitor_test.go @@ -27,10 +27,10 @@ func TestVisitor_TierUserDoesNotGetAnonymousUsage(t *testing.T) { require.Nil(t, err) v := s.visitor(netip.MustParseAddr("9.9.9.9"), nil) for i := 0; i < 3; i++ { - require.True(t, v.MessageAllowed()) + require.True(t, v.MessageAllowed().OK) } vu := s.visitor(netip.MustParseAddr("9.9.9.9"), u) - require.True(t, vu.RequestAllowed()) + require.True(t, vu.RequestAllowed().OK) require.Equal(t, int64(0), vu.Stats().Messages) require.Equal(t, int64(3), v.Stats().Messages) } @@ -47,8 +47,8 @@ func TestVisitor_BandwidthRefund(t *testing.T) { require.False(t, l.AllowN(200)) require.True(t, l.AllowN(-500)) require.True(t, l.AllowN(200)) - require.False(t, v.BandwidthAllowed(500)) - require.True(t, v.BandwidthAllowed(400)) + require.False(t, v.BandwidthAllowed(500).OK) + require.True(t, v.BandwidthAllowed(400).OK) } func TestVisitor_TierChangeKeepsBuckets(t *testing.T) { @@ -203,9 +203,9 @@ func TestVisitor_ZeroLimitsAdmitNothing(t *testing.T) { u, err := s.userManager.User("phil") require.Nil(t, err) v := s.visitor(netip.MustParseAddr("9.9.9.9"), u) - require.False(t, v.MessageAllowed()) - require.False(t, v.CallAllowed()) - require.False(t, v.BandwidthAllowed(1)) + require.False(t, v.MessageAllowed().OK) + require.False(t, v.CallAllowed().OK) + require.False(t, v.BandwidthAllowed(1).OK) require.False(t, v.BandwidthLimiter().AllowN(1)) }) } @@ -231,9 +231,9 @@ func TestVisitor_UserIsPerRequest(t *testing.T) { require.Equal(t, u.ID, authedAgain.User().ID) // Limits are still shared per IP: requests and the node-local subscription gauge - for authed.RequestAllowed() { + for authed.RequestAllowed().OK { } - require.False(t, anon.RequestAllowed()) + require.False(t, anon.RequestAllowed().OK) for authed.SubscriptionAllowed() { } require.False(t, anon.SubscriptionAllowed()) @@ -292,11 +292,11 @@ func TestVisitor_AuthLimiter_NotBypassedByConcurrentLogin(t *testing.T) { // The user logs in between the guesser's lookup and each failed guess for i := 0; i < 3; i++ { s.visitor(ip, u) - require.True(t, vip.AuthAllowed()) + require.True(t, vip.AuthAllowed().OK) vip.AuthFailed() } s.visitor(ip, u) - require.False(t, vip.AuthAllowed()) + require.False(t, vip.AuthAllowed().OK) } func TestVisitor_AccountLimiter_NotDeniedByConcurrentLogin(t *testing.T) { @@ -310,7 +310,7 @@ func TestVisitor_AccountLimiter_NotDeniedByConcurrentLogin(t *testing.T) { vip := s.visitor(ip, nil) s.visitor(ip, u) - require.True(t, vip.AccountActionAllowed()) + require.True(t, vip.AccountActionAllowed().OK) } func TestVisitor_RateVisitorKeptForExemptSubscriber(t *testing.T) { @@ -339,10 +339,10 @@ func TestVisitor_AuthAndAccountLimitsIgnoreUser(t *testing.T) { u, err := s.userManager.User("phil") require.Nil(t, err) v := s.visitor(netip.MustParseAddr("9.9.9.9"), u) - require.True(t, v.AccountActionAllowed()) - require.True(t, v.AuthAllowed()) + require.True(t, v.AccountActionAllowed().OK) + require.True(t, v.AuthAllowed().OK) v.AuthFailed() - require.False(t, v.AuthAllowed()) + require.False(t, v.AuthAllowed().OK) } func TestVisitor_SharedKeyCountsOnce(t *testing.T) { @@ -363,10 +363,10 @@ func TestVisitor_SharedKeyCountsOnce(t *testing.T) { v2 := s.visitor(netip.MustParseAddr("2.2.2.2"), u) require.Equal(t, v1.key, v2.key) for i := 0; i < 3; i++ { - require.True(t, v1.MessageAllowed()) + require.True(t, v1.MessageAllowed().OK) } for i := 0; i < 2; i++ { - require.True(t, v2.MessageAllowed()) + require.True(t, v2.MessageAllowed().OK) } require.Equal(t, int64(5), v1.Stats().Messages) require.Equal(t, int64(5), v2.Stats().Messages) @@ -381,9 +381,9 @@ func TestVisitor_SharedKeyCountsOnce(t *testing.T) { require.Equal(t, int64(5), account.Stats.Messages) require.Equal(t, int64(5), account.Stats.MessagesRemaining) for i := 0; i < 5; i++ { - require.True(t, v2.MessageAllowed()) + require.True(t, v2.MessageAllowed().OK) } - require.False(t, v1.MessageAllowed()) + require.False(t, v1.MessageAllowed().OK) }) } @@ -403,8 +403,8 @@ func TestVisitor_RequestLimiterZeroBurstAdmitsNothing(t *testing.T) { u, err := s.userManager.User("phil") require.Nil(t, err) ip := netip.MustParseAddr("9.9.9.9") - require.False(t, s.visitor(ip, nil).RequestAllowed()) - require.False(t, s.visitor(ip, u).RequestAllowed()) + require.False(t, s.visitor(ip, nil).RequestAllowed().OK) + require.False(t, s.visitor(ip, u).RequestAllowed().OK) } func TestVisitor_TopicCreationLimiterDisabledByZero(t *testing.T) { @@ -417,9 +417,9 @@ func TestVisitor_TopicCreationLimiterDisabledByZero(t *testing.T) { s := newTestServer(t, c) v := s.visitor(netip.MustParseAddr("9.9.9.9"), nil) for i := 0; i < 3; i++ { - require.True(t, v.TopicCreationAllowed()) + require.True(t, v.TopicCreationAllowed().OK) } - return v.TopicCreationAllowed() // The 4th: denied only if a 3-burst limiter is on + return v.TopicCreationAllowed().OK // The 4th: denied only if a 3-burst limiter is on } require.True(t, topicsAllowed(0, time.Hour)) require.True(t, topicsAllowed(3, 0)) @@ -447,7 +447,7 @@ func TestVisitor_TierChangeDoesNotReseedCounts(t *testing.T) { ip := netip.MustParseAddr("9.9.9.9") v := s.visitor(ip, u) for i := 0; i < 4; i++ { - require.True(t, v.MessageAllowed()) + require.True(t, v.MessageAllowed().OK) } require.Nil(t, s.userManager.ChangeTier("phil", "large")) u, err = s.userManager.User("phil") @@ -455,12 +455,12 @@ func TestVisitor_TierChangeDoesNotReseedCounts(t *testing.T) { v = s.visitor(ip, u) // The next request carries the new tier require.Equal(t, int64(4), v.Stats().Messages) for i := 0; i < 6; i++ { - require.True(t, v.MessageAllowed()) + require.True(t, v.MessageAllowed().OK) } - require.False(t, v.MessageAllowed()) + require.False(t, v.MessageAllowed().OK) fresh := s.visitor(ip, u) require.Equal(t, int64(10), fresh.Stats().Messages) - require.False(t, fresh.MessageAllowed()) + require.False(t, fresh.MessageAllowed().OK) }) } @@ -475,17 +475,17 @@ func TestVisitor_IPPrefixKeying(t *testing.T) { u, err := s.userManager.User("phil") require.Nil(t, err) v := s.visitor(netip.MustParseAddr("1.2.3.4"), nil) - require.True(t, v.MessageAllowed()) - require.True(t, v.MessageAllowed()) + require.True(t, v.MessageAllowed().OK) + require.True(t, v.MessageAllowed().OK) peer := s.visitor(netip.MustParseAddr("1.2.3.200"), nil) require.Equal(t, v.key, peer.key) require.Equal(t, int64(2), peer.Stats().Messages) - require.True(t, s.visitor(netip.MustParseAddr("1.2.4.1"), nil).MessageAllowed()) + require.True(t, s.visitor(netip.MustParseAddr("1.2.4.1"), nil).MessageAllowed().OK) vu := s.visitor(netip.MustParseAddr("1.2.3.77"), u) require.Equal(t, v.key, vu.key) - require.True(t, vu.MessageAllowed()) - require.False(t, vu.MessageAllowed()) - require.False(t, s.visitor(netip.MustParseAddr("1.2.3.4"), nil).MessageAllowed()) + require.True(t, vu.MessageAllowed().OK) + require.False(t, vu.MessageAllowed().OK) + require.False(t, s.visitor(netip.MustParseAddr("1.2.3.4"), nil).MessageAllowed().OK) // IPv6: the default /64 v6 := s.visitor(netip.MustParseAddr("2001:db8:1:2::1"), nil) @@ -507,8 +507,8 @@ func TestVisitor_TierDowngradeReturnsToIPBucket(t *testing.T) { require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) ip := netip.MustParseAddr("9.9.9.9") anon := s.visitor(ip, nil) - require.True(t, anon.MessageAllowed()) - require.True(t, anon.MessageAllowed()) + require.True(t, anon.MessageAllowed().OK) + require.True(t, anon.MessageAllowed().OK) // Up: fresh counts on the user key require.Nil(t, s.userManager.ChangeTier("phil", "test")) @@ -518,7 +518,7 @@ func TestVisitor_TierDowngradeReturnsToIPBucket(t *testing.T) { require.NotEqual(t, anon.key, vu.key) require.Equal(t, int64(0), vu.Stats().Messages) for i := 0; i < 5; i++ { - require.True(t, vu.MessageAllowed()) + require.True(t, vu.MessageAllowed().OK) } // Down: back on the IP key, with the anonymous usage @@ -529,8 +529,8 @@ func TestVisitor_TierDowngradeReturnsToIPBucket(t *testing.T) { vd := s.visitor(ip, u) require.Equal(t, anon.key, vd.key) require.Equal(t, int64(2), vd.Stats().Messages) - require.True(t, vd.MessageAllowed()) - require.False(t, vd.MessageAllowed()) + require.True(t, vd.MessageAllowed().OK) + require.False(t, vd.MessageAllowed().OK) // Up again: what was spent on the tier stays spent require.Nil(t, s.userManager.ChangeTier("phil", "test")) @@ -569,7 +569,7 @@ func TestVisitor_ConcurrentSharedKey(t *testing.T) { defer wg.Done() for i := 0; i < iterations; i++ { v := s.visitor(ip, u) - if v.MessageAllowed() { + if v.MessageAllowed().OK { admitted.Add(1) } v.RequestAllowed() @@ -611,7 +611,7 @@ func TestVisitor_BandwidthReaderRollback(t *testing.T) { require.ErrorIs(t, err, util.ErrLimitReached) require.Zero(t, n) require.Zero(t, l.Value()) - require.True(t, v.BandwidthAllowed(10)) + require.True(t, v.BandwidthAllowed(10).OK) require.False(t, l.Allow()) } @@ -663,9 +663,9 @@ func TestVisitor_EmailsWithoutTierHaveDailyCap(t *testing.T) { s := newTestServer(t, c) v := s.visitor(netip.MustParseAddr("9.9.9.9"), nil) for i := 0; i < 24; i++ { - require.True(t, v.EmailAllowed(), "email %d", i) + require.True(t, v.EmailAllowed().OK, "email %d", i) } - require.False(t, v.EmailAllowed()) + require.False(t, v.EmailAllowed().OK) } func TestVisitor_EmailsWithoutTierAllowedWithSlowRefill(t *testing.T) { @@ -676,9 +676,9 @@ func TestVisitor_EmailsWithoutTierAllowedWithSlowRefill(t *testing.T) { c.VisitorEmailLimitReplenish = 48 * time.Hour s := newTestServer(t, c) v := s.visitor(netip.MustParseAddr("9.9.9.9"), nil) - require.True(t, v.EmailAllowed()) - require.True(t, v.EmailAllowed()) - require.False(t, v.EmailAllowed()) + require.True(t, v.EmailAllowed().OK) + require.True(t, v.EmailAllowed().OK) + require.False(t, v.EmailAllowed().OK) } func TestVisitor_LoggedInUserWrittenFromFirstMessage(t *testing.T) { @@ -690,8 +690,8 @@ func TestVisitor_LoggedInUserWrittenFromFirstMessage(t *testing.T) { require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) u, err := s.userManager.User("ben") require.Nil(t, err) - require.True(t, s.visitor(netip.MustParseAddr("1.1.1.1"), u).MessageAllowed()) - require.True(t, s.visitor(netip.MustParseAddr("2.2.2.2"), nil).MessageAllowed()) + require.True(t, s.visitor(netip.MustParseAddr("1.1.1.1"), u).MessageAllowed().OK) + require.True(t, s.visitor(netip.MustParseAddr("2.2.2.2"), nil).MessageAllowed().OK) s.Stop() s = newTestServer(t, c) require.Equal(t, int64(1), s.visitor(netip.MustParseAddr("1.1.1.1"), u).Stats().Messages)