{-| Module: IHP.ErrorController Description: Provides web-based error screens for runtime errors in IHP Copyright: (c) digitally induced GmbH, 2020 -} module IHP.ErrorController ( displayException , handleNoResponseReturned , handleRouterException ) where import IHP.Prelude hiding (displayException) import qualified IHP.Controller.Param as Param import qualified IHP.Router.Types as Router import qualified Network.HTTP.Types.Method as Router import qualified Control.Exception as Exception import qualified Data.Text as Text import IHP.Controller.RequestContext import Network.HTTP.Types (status500, status400) import Network.Wai import Network.HTTP.Types.Header import qualified Text.Blaze.Html5 as H import qualified Text.Blaze.Html.Renderer.Utf8 as Blaze import qualified Database.PostgreSQL.Simple as PG import qualified Data.ByteString.Char8 as ByteString import IHP.HSX.QQ (hsx) import qualified IHP.ModelSupport as ModelSupport import IHP.FrameworkConfig import qualified IHP.Environment as Environment import IHP.Controller.Context import IHP.ApplicationContext import IHP.Controller.NotFound (handleNotFound) import qualified IHP.Log as Log handleNoResponseReturned :: (Show controller, ?context :: ControllerContext) => controller -> IO ResponseReceived handleNoResponseReturned controller = do let codeSample :: Text = "render MyView { .. }" let errorMessage = [hsx|
You can fix this by calling '{codeSample}' at the end of your action.
No response was returned while running the action {tshow controller}
|] let title = [hsx|No response returned in {tshow controller}|] let RequestContext { respond } = ?context.requestContext respond $ responseBuilder status500 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) displayException :: (Show action, ?context :: ControllerContext, ?applicationContext :: ApplicationContext, ?requestContext :: RequestContext) => SomeException -> action -> Text -> IO ResponseReceived displayException exception action additionalInfo = do -- Dev handlers display helpful tips on how to resolve the problem let devHandlers = [ postgresHandler , paramNotFoundExceptionHandler , patternMatchFailureHandler , recordNotFoundExceptionHandlerDev ] -- Prod handlers should not leak any information about the system let prodHandlers = [ recordNotFoundExceptionHandlerProd ] let allHandlers = if ?context.frameworkConfig.environment == Environment.Development then devHandlers else prodHandlers let supportingHandlers = allHandlers |> mapMaybe (\f -> f exception action additionalInfo) let displayGenericError = genericHandler exception action additionalInfo -- Additionally to rendering the error message to the browser we also send it -- to the error tracking service (e.g. sentry). Usually this service also writes -- the error message to the stderr output -- when (?context.frameworkConfig.environment == Environment.Production) do let exceptionTracker = ?applicationContext.frameworkConfig.exceptionTracker.onException let request = ?requestContext.request exceptionTracker (Just request) exception supportingHandlers |> head |> fromMaybe displayGenericError -- | Responds to all exceptions with a generic error message. -- -- In dev mode the action and exception is added to the output. -- In production mode nothing is specific is communicated about the exception genericHandler :: (Show controller, ?context :: ControllerContext) => Exception.SomeException -> controller -> Text -> IO ResponseReceived genericHandler exception controller additionalInfo = do let errorMessageText = "An exception was raised while running the action " <> tshow controller <> additionalInfo let errorMessageTitle = Exception.displayException exception let devErrorMessage = [hsx|{errorMessageText}|] let devTitle = [hsx|{errorMessageTitle}|] Log.error (errorMessageText <> ": " <> cs errorMessageTitle) let prodErrorMessage = [hsx|An exception was raised while running the action|] let prodTitle = [hsx|An error happened|] let (errorMessage, errorTitle) = if ?context.frameworkConfig.environment == Environment.Development then (devErrorMessage, devTitle) else (prodErrorMessage, prodTitle) let RequestContext { respond } = ?context.requestContext respond $ responseBuilder status500 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError errorTitle errorMessage)) postgresHandler :: (Show controller, ?context :: ControllerContext) => SomeException -> controller -> Text -> Maybe (IO ResponseReceived) postgresHandler exception controller additionalInfo = do let handlePostgresOutdatedError :: Show exception => exception -> H.Html -> IO ResponseReceived handlePostgresOutdatedError exception errorText = do let ihpIdeBaseUrl = ?context.frameworkConfig.ideBaseUrl let title = [hsx|Database looks outdated. {errorText}|] let errorMessage = [hsx|The exception was raised while running the action: {tshow controller}{additionalInfo}
{tshow exception}
|] let RequestContext { respond } = ?context.requestContext respond $ responseBuilder status500 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) handleSqlError :: ModelSupport.EnhancedSqlError -> IO ResponseReceived handleSqlError exception = do let ihpIdeBaseUrl = ?context.frameworkConfig.ideBaseUrl let sqlError = exception.sqlError let title = [hsx|{sqlError.sqlErrorMsg}|] let errorMessage = [hsx|{exception.sqlErrorQuery}
{exception.sqlErrorQueryParams}
The exception was raised while running the action: {tshow controller}{additionalInfo}
{tshow exception}
|] let RequestContext { respond } = ?context.requestContext respond $ responseBuilder status500 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) case fromException exception of Just (exception :: PG.ResultError) -> Just (handlePostgresOutdatedError exception "The database result does not match the expected type.") Nothing -> case fromException exception of -- Catching `relation "..." does not exist` Just exception@ModelSupport.EnhancedSqlError { sqlError } | "relation" `ByteString.isPrefixOf` (sqlError.sqlErrorMsg) && "does not exist" `ByteString.isSuffixOf` (sqlError.sqlErrorMsg) -> Just (handlePostgresOutdatedError exception "A table is missing.") -- Catching `columns "..." does not exist` Just exception@ModelSupport.EnhancedSqlError { sqlError } | "column" `ByteString.isPrefixOf` (sqlError.sqlErrorMsg) && "does not exist" `ByteString.isSuffixOf` (sqlError.sqlErrorMsg) -> Just (handlePostgresOutdatedError exception "A column is missing.") -- Catching other SQL Errors Just exception -> Just (handleSqlError exception) Nothing -> Nothing patternMatchFailureHandler :: (Show controller, ?context :: ControllerContext) => SomeException -> controller -> Text -> Maybe (IO ResponseReceived) patternMatchFailureHandler exception controller additionalInfo = do case fromException exception of Just (exception :: Exception.PatternMatchFail) -> Just do let (controllerPath, _) = Text.breakOn ":" (tshow exception) let errorMessage = [hsx|a) Maybe the action function is missing for {tshow controller}? You can fix this by adding an action handler like this to the controller '{controllerPath}':
{codeSample}
b) A pattern match like 'let (Just value) = ...' failed. Please see the details section.
{exception}
|] where codeSample = " action (" <> tshow controller <> ") = do\n renderPlain \"Hello World\"" let title = [hsx|Pattern match failed while executing {tshow controller}|] let RequestContext { respond } = ?context.requestContext respond $ responseBuilder status500 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) Nothing -> Nothing -- Handler for 'IHP.Controller.Param.ParamNotFoundException' paramNotFoundExceptionHandler :: (Show controller, ?context :: ControllerContext) => SomeException -> controller -> Text -> Maybe (IO ResponseReceived) paramNotFoundExceptionHandler exception controller additionalInfo = do case fromException exception of Just (exception@(Param.ParamNotFoundException paramName)) -> Just do let (controllerPath, _) = Text.breakOn ":" (tshow exception) let renderParam (paramName, paramValue) = [hsx|?{paramName}=someValue
to the URL.
|]
else [hsx|
The following parameters are provided by the request:
a) Is there a typo in your call to param {tshow paramName}
?
b) You can pass this parameter by appending &{paramName}=someValue
to the URL.
c) You can pass this parameter using a form input like {" paramName <> "\"/>" :: ByteString}
.
param {tshow paramName}
in {tshow controller}.
A request parameter is just a query parameter like /MyAction?someParameter=someValue&secondParameter=1
or a form input when the request was submitted from a html form or via ajax.
{exception}
|] let title = [hsx|Parameter{paramName}not found in the request|] let RequestContext { respond } = ?context.requestContext respond $ responseBuilder status500 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) Just (exception@(Param.ParamCouldNotBeParsedException { name, parserError })) -> Just do let (controllerPath, _) = Text.breakOn ":" (tshow exception) let renderParam (paramName, paramValue) = [hsx|
param {tshow name}
in {tshow controller}.
Here's the error output from the parser: {parserError}
{exception}
|] let title = [hsx|Parameter{name}was invalid|] let RequestContext { respond } = ?context.requestContext respond $ responseBuilder status500 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) Nothing -> Nothing -- Handler for 'IHP.ModelSupport.RecordNotFoundException' -- -- Used only in development mode of the app. recordNotFoundExceptionHandlerDev :: (Show controller, ?context :: ControllerContext) => SomeException -> controller -> Text -> Maybe (IO ResponseReceived) recordNotFoundExceptionHandlerDev exception controller additionalInfo = case fromException exception of Just (exception@(ModelSupport.RecordNotFoundException { queryAndParams = (query, params) })) -> Just do let (controllerPath, _) = Text.breakOn ":" (tshow exception) let errorMessage = [hsx|
The following SQL was executed:
{query}
These query parameters have been used:
{params}
This exception was caused by a call to fetchOne
in {tshow controller}.
a) Use fetchOneOrNothing. This will return a Nothing when no results are returned by the database.
b) Make sure the the data you are querying is actually there.
{exception}
|] let title = [hsx|Call to fetchOne failed. No records returned.|] let RequestContext { respond } = ?context.requestContext respond $ responseBuilder status500 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) Nothing -> Nothing -- Handler for 'IHP.ModelSupport.RecordNotFoundException' -- -- Used only in production mode of the app. The exception is handled by calling 'handleNotFound' recordNotFoundExceptionHandlerProd :: (?context :: ControllerContext) => SomeException -> controller -> Text -> Maybe (IO ResponseReceived) recordNotFoundExceptionHandlerProd exception controller additionalInfo = case fromException exception of Just (exception@(ModelSupport.RecordNotFoundException {})) -> let requestContext = ?context.requestContext in let ?context = requestContext in Just (handleNotFound ?context.request ?context.respond) Nothing -> Nothing handleRouterException :: (?applicationContext :: ApplicationContext) => SomeException -> Application handleRouterException exception request respond = let ?context = ?applicationContext in case fromException exception of Just Router.NoConstructorMatched { expectedType, value, field } -> do let routingError = if ?context.frameworkConfig.environment == Environment.Development then [hsx|Routing failed with: {tshow exception}
|] else "" let errorMessage = [hsx| { routingError }You can pass this parameter by appending &{field}=someValue
to the URL.
{value}|] Nothing -> [hsx|The action was called without the required
{field}parameter|] respond $ responseBuilder status400 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) Just Router.BadType { expectedType, value = Just value, field } -> do let errorMessage = [hsx|
Routing failed with: {tshow exception}
|] let title = [hsx|Query parameter{field}needs to be a
{expectedType}but got
{value}|] respond $ responseBuilder status400 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) _ -> case fromException exception of Just Router.UnexpectedMethodException { allowedMethods = [Router.DELETE], method = Router.GET } -> do let exampleLink :: Text = "Delete Project" let formExample :: Text = cs [plain| |] let errorMessage = [hsx|
You cannot directly link to Delete Action. GET requests should not have any external side effects, as a user could accidentally trigger it by following a normal link.
a) Add a js-delete
class to your link. IHP's helper.js will intercept link clicks on these links and use a form with a DELETE request to submit the request.
Example:
{exampleLink}
b) Use a form to submit the request as a DELETE request:
Example:
{formExample}HTML forms don't support DELETE requests natively, therefore we use the hidden input field to work around this browser limitation. |] let title = [hsx|Action was called from a GET request, but needs to be called as a DELETE request|] respond $ responseBuilder status400 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) Just Router.UnexpectedMethodException { allowedMethods = [Router.POST], method = Router.GET } -> do let errorMessage = [hsx|
You cannot directly link to Create Action. GET requests should not have any external side effects, as a user could accidentally trigger it by following a normal link.
Make a form with formFor
to do the request
Routing failed with: {tshow exception}
Make a form with formFor
to do the request
{allowedMethods}|] respond $ responseBuilder status400 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) _ -> do let errorMessage = [hsx| Routing failed with: {tshow exception}
Are you trying to do a DELETE action, but your link is missing class="js-delete"?
|] let title = H.text "Routing failed" respond $ responseBuilder status500 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) renderError :: forall context. (?context :: context, ConfigProvider context) => H.Html -> H.Html -> H.Html renderError errorTitle view = [hsx|