A while ago Eric Kidd wrote a rant about inconsistent error reporting mechanisms in Haskell. He found eight different idioms, none of which were completely satisfying. In this post I want to propose a very simple but IMO pretty useful and easy-to-use scheme, that works with standard Haskell.
The Haskell HTTP Package is a good test case for such scheme. The most immediate requirements are:
- It should work from within any monad (not just
IO
). - It should be possible to catch and identify any kind of error that happened inside a call to a library routine.
- It should be possible to ignore the error-handling (e.g., for simple scripts that just die in case of error)
So far, the public API functions mostly have a signature like
type Result a = Either ConnError a simpleHTTP :: Request -> IO (Result Response)
This requires C-style coding where we have to check for an error after each call. Additionally, we might still get an IOException
, and have to catch it somewhere else (if we want to). A simple workaround is to write a wrapper function for calls to the HTTP API. For example:
data MyErrorType = ... | HTTPErr ConnError | IOErr IOException instance Error MyErrorType where noMsg = undefined -- who needs these anyways? strMsg _ = undefined instance MonadError MyErrorType MyMonad where ... -- | Perform the API action and transform any error into our custom -- error type and re-throw it in our custom error type. ht :: IO (Result a) -> MyMonad a ht m = do { r <- io m ; case r of Left cerr -> throwError (HTTPErr cerr) Right x -> return x } -- | Perform an action in the IO monad and re-throw possible -- IOExceptions as our custom error type. io :: IO a -> MyMonad a io m = do { r <- liftIO $ (m >>= return . Right) `catchError` (\e -> return (Left e)) ; case r of Left e -> throwError (IOErr e) Right a -> return a }
We defined a custom error type, because we can have only one error type per monad. Exceptions in the IO monad and API error messages are then caught immediately and wrapped in our custom error type.
But why should every user of the library do that? Can't we just fix the library? Of course we can! Now, that we have a specific solution we can go and generalize. Let's start by commenting out the type signatures of ht
and io
and ask ghci
what it thinks about the functions' types:
*Main> :t io io :: (MonadIO m, MonadError MyErrorType m) => IO a -> m a *Main> :t ht ht :: (MonadIO t, MonadError MyErrorType t) => IO (Either ConnError t1) -> t t1
Alright, this already looks pretty general. There's still our custom MyErrorType
in the signature, though. To fix this we apply the standard trick and use a type class.
data HTTPErrorType = ConnErr ConnError | IOErr IOException -- | An instance of this class can embed 'HTTPError's. class HTTPError e where fromHTTPError :: HTTPErrorType -> e
Our wrapper functions now have a nice general type, that allows us to move them into the library.
throwHTTPError = throwError . fromHTTPError ht :: (MonadError e m, MonadIO m, HTTPError e) => IO (Result a) -> m a ht m = do { r <- io m ; case r of Left cerr -> throwHTTPError (ConnErr cerr) Right a -> return a } -- | Perform an action in the IO monad and re-throw possible -- IOExceptions as our custom error type. io :: (MonadError e m, MonadIO m, HTTPError e) => IO a -> m a io m = do r <- liftIO $ (m >>= return . Right) `catchError` (\e -> return (Left e)) case r of Left e -> throwHTTPError (IOErr e) Right a -> return a
After wrapping, all exported functions will have a signature of the form:
f :: (MonadError e m, MonadIO m, HTTPError e) => ... arguments ... -> m SomeResultType
Now the user is free to choose whichever monad she wants (that allows throwing errors and I/O). The only added burden is for the user to specify how to embed a HTTPError
in the respective error type of the monad. We can already specify the instance for IO
, though.
instance HTTPError IOException where fromHTTPError (IOErr e) = e fromHTTPError (ConnErr e) = userError $ show e
This way, our modified API works nicely out of the box whenever we just use the IO
monad and we can use it in our custom monad by writing only one simple instance declaration.
data MyErrorType = ... | HTTPErr HTTPErrorType instance HTTPError MyErrorType where fromHTTPError = HTTPErr test1 req = do { r <- simpleHTTP req ; putStrLn (rspCode r) } `catchError` handler where handler (HTTPErr (ConnErr e)) = putStrLn $ "Connection error." handler (HTTPErr (IOErr e)) = putStrLn $ "I/O Error." handler _ = putStrLn $ "Whatever."
If we don't care about the error and thus don't want to implement the instance, we can still force our API to be in the IO
monad and thus reuse IOException
to embed possible HTTP errors.
test2 req = do { r <- liftIO $ simpleHTTP req ; putStrLn (rspCode r) }
I think this is a very simple but useful scheme. I already implemented this with a friend in the HTTP package—and it works (without -fglasgow-exts
).
In addition to the added type class, there is the further potential drawback that an IOException
will always be wrapped in an API-specific error type. So when a program uses more than one API that uses this scheme, an IOException
may be wrapped in either, which may or may not be what is desired. A more sophisticated system, that deals with this problem and provides additional features, is explain in Simon Marlow's paper "An Extensible Dynamically-Typed Hierarchy of Exceptions" (PDF).
Comments welcome.
If anyone is interested the (kind of up to date) darcs repository is available at http://www.dtek.chalmers.se/~tox/darcs/http
ReplyDelete