Skip to content

Add the ability to specify custom error bodyΒ #5

@chshersh

Description

@chshersh

First of all, thanks for writing this great library! 😊
It works like clockwork πŸ•

While using servant-rate-limit, I stumbled on issue about not being able to specify custom error body when the exception is thrown. It's empty by default and this results in some JSON parsing errors. And this is hardcoded here:

unless allowRequest $ delayedFailFatal $ ServerError{
errHTTPCode = 429,
errReasonPhrase = "Rate limit exceeded",
errBody = "",
errHeaders = []
}

It would be great to be able to specify custom error body (and headers). Here is the design I came up with.

The main idea is to introduce a typeclass that allows to return a custom error body and list of headers based on request.

-- | A typeclass for types that tell how to produce error body and relevant headers from 'Request' in
-- errors with the 429 code (Rate Limit Exceeded).
class HasRateLimitErrBody err where
  getErrBody :: Request -> IO err
  mkErrBody  :: err -> Request -> (ByteString, [Header])

A few possible instances of the HasRateLimitErrBody typeclass:

-- | Empty error body with no text and headers
data EmptyErrorBody = EmptyErrorBody

instance HasRateLimitErrBody EmptyErrorBody where
  getErrBodySetter _ = pure EmptyErrorBody
  mkErrBody _ _ = ("", [])  -- to emulate the existing behaviour of 'RateLimit'

-- | Simple hardcoded error body as JSON
data SimpleJsonErrBody = SimpleJsonErrBody

instance HasRateLimitErrBody SimpleJsonErrBody where
  getErrBodySetter _ = pure SimpleJsonErrBody
  mkErrBody _ _ =
    let errBody =     Aeson.encode $
          Aeson.object
            [ ( "message",
                "We received too many requests from your device in a short time, please try again in a few minutes."
              )
            ]
        headers = [(hContentType, renderHeader $ contentType (Proxy @JSON))]
    in (errBody, headers)

After that, we can implement a data type similar to 'RateLimitbut with customerrBody`.

data RateLimitCustom errBody strategy policy

You may want to introduce a breaking change in a form of changing the existing RateLimit to this one or reimplementing RateLimit as type RateLimit = RateLimitCustom EmptyErrorBody but I personally tend to avoid introducing breaking changes (at least unless there's a clear migration guide).

After that, it's possible to implement a HasServer instance for newly introduced RateLimitCustom. It's almost the same as the existing instance with a few changes:

instance
    ( HasServer api ctx
    , HasContextEntry ctx (Backend key)
+   , HasRateLimitErrBody errBody,
    , HasRateLimitStrategy strategy
    , HasRateLimitPolicy policy
    , key ~ RateLimitPolicyKey policy
-   ) => HasServer (RateLimit strategy policy :> api) ctx
+   ) => HasServer (RateLimitCustom errBody strategy policy :> api) ctx
    where

    type ServerT (RateLimit strategy policy :> api) m = ServerT api m

    hoistServerWithContext _ pc nt s =
        hoistServerWithContext (Proxy :: Proxy api) pc nt s

    route _ context subserver = do
        -- retrieve the backend from the Servant context
        let backend = getContextEntry context

        -- retrieve the rate-limiting policy used to identify clients
        let policy = policyGetIdentifier @policy

        -- retrieve the rate-limiting strategy used to limit access
        let strategy = strategyValue @strategy @key backend policy

        let rateCheck = withRequest $ \req -> do
                -- apply the rate-limiting strategy to the request
                allowRequest <- liftIO $ strategyOnRequest strategy req

                -- fail if the rate limit has been exceeded
                unless allowRequest $ do
+                   errBodySetter <- liftIO $ getErrBodySetter @errBody req
+                   let (customErrBody, customHeaders) = mkErrBody errBodySetter req
                    delayedFailFatal $ ServerError{
                        errHTTPCode = 429,
                        errReasonPhrase = "Rate limit exceeded",
-                       errBody = "",
+                       errBody = customErrBody,
-                       errHeaders = []
+                       errHeaders = customHeaders
                    }

        -- add the check for whether the rate limit has been exceeded to the
        -- server and return it
        route (Proxy :: Proxy api) context $
            subserver `addAcceptCheck` rateCheck

If this is something you would like to have in your library, I'm happy to contribute the implementation πŸ€—

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions