-
Notifications
You must be signed in to change notification settings - Fork 7
ADR 8 Use RIO in cardano‐cli
- Adopted 2025/02/10
In cardano-cli
we are using ExceptT someError IO someResult
pattern 292 times in function types in our codebase for different error types.
The vast majority of these errors are not used to recover, they are propagated and reported to the user.
main :: IO ()
main = toplevelExceptionHandler $ do
envCli <- getEnvCli
co <- Opt.customExecParser pref (opts envCli)
orDie (docToText . renderClientCommandError) $ runClientCommand co
...
runClientCommand :: ClientCommand -> ExceptT ClientCommandErrors IO ()
runClientCommand = \case
AnyEraCommand cmds ->
firstExceptT (CmdError (renderAnyEraCommand cmds)) $ runAnyEraCommand cmds
AddressCommand cmds ->
firstExceptT AddressCmdError $ runAddressCmds cmds
NodeCommands cmds ->
runNodeCmds cmds
& firstExceptT NodeCmdError
ByronCommand cmds ->
firstExceptT ByronClientError $ runByronClientCommand cmds
CompatibleCommands cmd ->
firstExceptT (BackwardCompatibleError (renderAnyCompatibleCommand cmd)) $
runAnyCompatibleCommand cmd
...
- As a result we have a lot of errors wrapped in errors which makes the code unwieldly and difficult to compose with other code blocks. See image below of incidences of poor composability where we use
firstExceptT
(and sometimesfirst
) to wrap errors in other errors.
- The ExceptT IO is a known anti-pattern for these reasons and others as per:
I propose to replace ExceptT someError IO a
with RIO env a.
Below is how cardano-cli
is currently structured. ExceptT
with errors wrapping errors. However the errors ultimately end up being rendered at the top level to the user.
-- TOP LEVEL --
data ExampleClientCommand = ClientCommandTransactions ClientCommandTransactions
data ExampleClientCommandErrors
= CmdError CmdError
-- | ByronClientError ByronClientCmdError
-- | AddressCmdError AddressCmdError
-- ...
data CmdError
= ExampleTransactionCmdError ExampleTransactionCmdError
-- | AddressCommand AnyEraCommand
-- | ByronCommand AddressCmds
-- ...
topLevelRunCommand :: ExampleClientCommand -> ExceptT ExampleClientCommandErrors IO ()
topLevelRunCommand (ClientCommandTransactions txsCmd) =
firstExceptT (CmdError . ExampleTransactionCmdError) $ runClientCommandTransactions txsCmd
-- SUB LEVEL --
data ClientCommandTransactions = DummyClientCommandToRun
data ExampleTransactionCmdError
= TransactionWriteFileError !(FileError ())
runClientCommandTransactions
:: ()
=> ClientCommandTransactions
-> ExceptT ExampleTransactionCmdError IO ()
runClientCommandTransactions DummyClientCommandToRun =
...
left $
TransactionWriteFileError $
FileError "dummy.file" ()
Proposed change:
data ClientCommandTransactions = DummyClientCommandToRun
data ExampleClientCommand = ClientCommandTransactions ClientCommandTransactions
topLevelRunCommand :: ExampleClientCommand -> RIO () ()
topLevelRunCommand (ClientCommandTransactions txsCmd) =
runClientCommandTransactions txsCmd
runClientCommandTransactions
:: HasCallStack
=> ClientCommandTransactions
-> RIO () ()
runClientCommandTransactions DummyClientCommandToRun =
...
throwIO $
CustomCliException $
FileError "dummy.file" ()
We have eliminated data ExampleClientCommandErrors
and data CmdError
and improved the composability of our code.
- Additional logging functionality
- Explicit environment dependencies e.g
logError :: HasLogFunc env => Text -> RIO env ()
- Better composability i.e no more errors that wrap errors (see above).
-
RIO
is hardcoded to IO so we cannot add additional transformer layers e.gRIO Env (StateT Int IO) a
- Implicit error flow. Errors are thrown via GHC exceptions in
IO
. See exception handling below.
data CustomCliException where
CustomCliException
:: (Show error, Typeable error, Error error, HasCallStack)
=> error -> CustomCliException
deriving instance Show CustomCliException
instance Exception CustomCliException where
displayException (CustomCliException e) =
unlines
[ show (prettyError e)
, prettyCallStack callStack
]
throwCliError :: MonadIO m => CustomCliException -> m a
throwCliError = throwIO
The purpose of CustomCliException
is to represent explicitly thrown, structured errors that are meaningful to our application.
- Unified Exception Type
- Simplifies Top-Level Handling: All errors are caught as
CustomCliException
. - Consistent Reporting: Ensures all errors are formatted uniformly via
prettyError
.
- CallStack Inclusion
- Embeds CallStack to trace error origins, aiding debugging.
- Polymorphic Error Support
- Flexibility: Wraps any error type so long as the required instances exist.
- Type Erasure
- Loss of Specificity: Existential quantification erases concrete error types, preventing pattern-matching on specific errors. That may make specific error recovery logic harder to implement.
- Error handling: All errors will be converted to exceptions that will be caught by a single exception handler at the top level.
- Top level monad: RIO.
- We agree not to catch
CustomCliException
except in the top-level handler. If the need for catching an exception arises, we locally use anEither
orExceptT
pattern instead. -
CustomCliException
should only be thrown within theRIO
monad. Pure code is still not allowed to throw exceptions.
- This should dramatically improve our code's composability and remove many unnecessary error types.
- Readability concerning what errors can be thrown will be negatively impacted. However,
ExceptT
already lies about what exceptions can be thrown because it is not limited to the error type stated inExceptT
's type signature. In other words,IO
can implicitly throw otherException
s. - Initially, this will be adopted under the "compatible" group of commands so
cardano-cli
will have a design split temporarily. Once we are happy with the result we will propagate to the rest ofcardano-cli
The cardano-node
wiki has moved. Please go to (https://github.com/input-output-hk/cardano-node-wiki/wiki) and look for the page there.