Send a Retry-After header with rate limit 429 responses

This commit is contained in:
binwiederhier committed 2026-11-01 11:47:56 +00:00
1 parent b916f216fc
commit 5306dfaa97
9 files changed
+376 -216

No files matched your search

+134 -113
View File
@@ -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}
)
+25 -14
View File
@@ -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)
}
+8 -8
View File
@@ -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)
+3
View File
@@ -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()))
})
}
+2 -2
View File
@@ -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 {
+4 -4
View File
@@ -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)
}
+125 -1
View File
@@ -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 {
+28 -27
View File
@@ -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
+47 -47
View File
@@ -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)