packages feed

notion-client-0.8.0.0: src/Notion/V1/Error.hs

-- | Error handling for Notion API
--
-- A failed request surfaces as one of these exceptions:
--
-- * 'NotionError': Notion answered with its JSON error envelope. 'code' is a
--   typed 'APIErrorCode'.
-- * 'UnknownHTTPResponseError': a non-2xx response whose body is not a Notion
--   error, for example an HTML page from Notion's edge proxy.
-- * 'RequestTimeoutError': connecting or waiting for response headers timed out.
-- * 'InvalidPathParameterError': the request path contained @..@ and was not sent.
--
-- Response decoding failures and other connection problems remain servant's
-- 'Client.ClientError'.
module Notion.V1.Error
  ( -- * Error codes
    APIErrorCode (..),
    apiErrorCodeText,
    parseAPIErrorCode,

    -- * Error types
    NotionError (..),
    HttpErrorResponse (..),
    UnknownHTTPResponseError (..),
    unknownResponseMessage,
    RequestTimeoutError (..),
    InvalidPathParameterError (..),

    -- * Building errors
    buildRequestError,
    notionErrorFromResponse,
    fromClientError,
    parseNotionError,
    lookupHeader,
  )
where

import Control.Exception (Exception (..), SomeException, toException)
import Data.Aeson ((.!=), (.:), (.:?), (.=))
import Data.Aeson qualified as Aeson
import Data.Foldable (toList)
import Data.Maybe (catMaybes, fromMaybe)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Network.HTTP.Client qualified as HTTP
import Network.HTTP.Types (HeaderName, ResponseHeaders, Status (..))
import Notion.Prelude
import Servant.Client qualified as Client

-- | The error codes Notion documents, plus a fallback for new ones.
data APIErrorCode
  = Unauthorized
  | RestrictedResource
  | ObjectNotFound
  | RateLimited
  | InvalidJSON
  | InvalidRequestURL
  | InvalidRequest
  | InvalidBeta
  | ValidationError
  | ConflictError
  | InternalServerError
  | ServiceOverload
  | ServiceUnavailable
  | GatewayTimeout
  | -- | A code this library does not know yet, carried verbatim.
    UnknownErrorCode Text
  deriving stock (Eq, Show)

knownErrorCodes :: [(APIErrorCode, Text)]
knownErrorCodes =
  [ (Unauthorized, "unauthorized"),
    (RestrictedResource, "restricted_resource"),
    (ObjectNotFound, "object_not_found"),
    (RateLimited, "rate_limited"),
    (InvalidJSON, "invalid_json"),
    (InvalidRequestURL, "invalid_request_url"),
    (InvalidRequest, "invalid_request"),
    (InvalidBeta, "invalid_beta"),
    (ValidationError, "validation_error"),
    (ConflictError, "conflict_error"),
    (InternalServerError, "internal_server_error"),
    (ServiceOverload, "service_overload"),
    (ServiceUnavailable, "service_unavailable"),
    (GatewayTimeout, "gateway_timeout")
  ]

-- | The wire string of a code, for example @"object_not_found"@.
apiErrorCodeText :: APIErrorCode -> Text
apiErrorCodeText = \case
  UnknownErrorCode t -> t
  c -> fromMaybe "" (lookup c knownErrorCodes)

-- | Parse a wire string; unrecognised strings become 'UnknownErrorCode'.
parseAPIErrorCode :: Text -> APIErrorCode
parseAPIErrorCode t =
  maybe (UnknownErrorCode t) fst (lookupByText t)
  where
    lookupByText x = case filter ((== x) . snd) knownErrorCodes of
      pair : _ -> Just pair
      [] -> Nothing

-- | Lets string literals such as @"validation_error"@ stand for codes.
instance IsString APIErrorCode where
  fromString = parseAPIErrorCode . Text.pack

instance FromJSON APIErrorCode where
  parseJSON = Aeson.withText "APIErrorCode" (pure . parseAPIErrorCode)

instance ToJSON APIErrorCode where
  toJSON = String . apiErrorCodeText

-- | Metadata of the HTTP response an error came from.
data HttpErrorResponse = HttpErrorResponse
  { httpStatus :: Int,
    -- | Response headers; names compare case-insensitively.
    errorHeaders :: ResponseHeaders,
    -- | The @x-notion-request-id@ header.
    notionRequestId :: Maybe Text,
    -- | The @cf-ray@ header (Cloudflare Ray ID).
    rayId :: Maybe Text,
    -- | The raw response body.
    errorBody :: ByteString
  }
  deriving stock (Eq, Show)

-- | A well-formed Notion API error response.
data NotionError = NotionError
  { -- | Always @"error"@.
    object :: Text,
    -- | The body's @status@, or else the HTTP status.
    status :: Natural,
    code :: APIErrorCode,
    message :: Text,
    -- | The body's @request_id@, or else the @x-notion-request-id@ header.
    requestId :: Maybe Text,
    -- | The body's @additional_data@.
    additionalData :: Maybe Value,
    -- | Legacy field, kept for compatibility.
    details :: Maybe Value,
    -- | 'Nothing' when decoded from bare JSON rather than an HTTP response.
    response :: Maybe HttpErrorResponse
  }
  deriving stock (Eq, Show)

instance Exception NotionError

instance FromJSON NotionError where
  parseJSON = Aeson.withObject "NotionError" $ \o -> do
    object <- o .:? "object" .!= "error"
    status <- o .:? "status" .!= 0
    code <- o .: "code"
    message <- o .: "message"
    requestId <- o .:? "request_id"
    additionalData <- o .:? "additional_data"
    details <- o .:? "details"
    pure NotionError {response = Nothing, ..}

instance ToJSON NotionError where
  toJSON NotionError {..} =
    Aeson.object $
      [ "object" .= object,
        "status" .= status,
        "code" .= code,
        "message" .= message
      ]
        <> catMaybes
          [ ("request_id" .=) <$> requestId,
            ("additional_data" .=) <$> additionalData,
            ("details" .=) <$> details
          ]

-- | A non-2xx response whose body is not a Notion error envelope.
newtype UnknownHTTPResponseError = UnknownHTTPResponseError {unknownResponse :: HttpErrorResponse}
  deriving stock (Eq, Show)

instance Exception UnknownHTTPResponseError where
  displayException = Text.unpack . unknownResponseMessage

-- | Human-readable description, matching the JS SDK. When the response came
-- from Notion's edge proxy (a @cf-ray@ header but no request ID), it explains
-- that and includes the Ray ID for support.
unknownResponseMessage :: UnknownHTTPResponseError -> Text
unknownResponseMessage (UnknownHTTPResponseError HttpErrorResponse {..}) =
  case rayId of
    Just ray
      | Nothing <- notionRequestId ->
          base
            <> ". The response was returned by Notion's edge proxy before reaching the Notion API"
            <> maybe "" (\ct -> " (content-type: " <> ct <> ")") (lookupHeader "content-type" errorHeaders)
            <> "."
            <> (if httpStatus == 403 then " This may mean the request was blocked by a network security rule." else "")
            <> " Cloudflare Ray ID: "
            <> ray
            <> ". Include this ID when contacting Notion support."
    _ -> base
  where
    base = "Request to Notion API failed with status: " <> Text.pack (show httpStatus)

-- | Connecting to Notion or waiting for its response headers timed out.
data RequestTimeoutError = RequestTimeoutError
  deriving stock (Eq, Show)

instance Exception RequestTimeoutError where
  displayException _ = "Request to Notion API has timed out"

-- | The request path contained a path traversal sequence; nothing was sent.
newtype InvalidPathParameterError = InvalidPathParameterError {invalidPath :: Text}
  deriving stock (Eq, Show)

instance Exception InvalidPathParameterError where
  displayException (InvalidPathParameterError p) =
    "Request path \"" <> Text.unpack p <> "\" contains path traversal sequence \"..\""

-- | First value of a header, decoded leniently as UTF-8.
lookupHeader :: HeaderName -> ResponseHeaders -> Maybe Text
lookupHeader name hs = Text.decodeUtf8Lenient <$> lookup name hs

-- | Classify a non-2xx response from its status, headers and body.
buildRequestError :: Int -> ResponseHeaders -> ByteString -> Either UnknownHTTPResponseError NotionError
buildRequestError httpStatus errorHeaders errorBody =
  case Aeson.decode errorBody of
    Just err@NotionError {status, requestId} ->
      Right
        err
          { status = if status == 0 then fromIntegral httpStatus else status,
            requestId = maybe notionRequestId Just requestId,
            response = Just meta
          }
    Nothing -> Left (UnknownHTTPResponseError meta)
  where
    notionRequestId = lookupHeader "x-notion-request-id" errorHeaders
    rayId = lookupHeader "cf-ray" errorHeaders
    meta = HttpErrorResponse {..}

-- | Typed exception for a non-2xx response: a 'NotionError' when the body is a
-- Notion error envelope, else an 'UnknownHTTPResponseError'. Throwing the result
-- with 'Control.Exception.throwIO' can be caught as either type.
notionErrorFromResponse :: Status -> ResponseHeaders -> ByteString -> SomeException
notionErrorFromResponse s hs b = either toException toException (buildRequestError (statusCode s) hs b)

-- | Convert a servant client error into the exception this library throws.
fromClientError :: Client.ClientError -> SomeException
fromClientError = \case
  Client.FailureResponse _ resp ->
    notionErrorFromResponse
      (Client.responseStatusCode resp)
      (toList (Client.responseHeaders resp))
      (Client.responseBody resp)
  err@(Client.ConnectionError e)
    | Just (HTTP.HttpExceptionRequest _ content) <- fromException e,
      isTimeout content ->
        toException RequestTimeoutError
    | otherwise -> toException err
  err -> toException err
  where
    isTimeout = \case
      HTTP.ResponseTimeout -> True
      HTTP.ConnectionTimeout -> True
      _ -> False

-- | Try to parse a 'NotionError' from a Servant 'Client.ClientError'.
--
-- Returns 'Just' if the error is a 'Client.FailureResponse' whose body is a
-- Notion error envelope, 'Nothing' otherwise.
parseNotionError :: Client.ClientError -> Maybe NotionError
parseNotionError = \case
  Client.FailureResponse _req resp ->
    either (const Nothing) Just $
      buildRequestError
        (statusCode (Client.responseStatusCode resp))
        (toList (Client.responseHeaders resp))
        (Client.responseBody resp)
  _ -> Nothing