|
| 1 | +# Status |
| 2 | +- [ ] Adopted YYYY/MM/DD |
| 3 | + |
| 4 | +# Recommended Reading |
| 5 | + |
| 6 | +- [ADR 8](https://github.com/input-output-hk/cardano-node-wiki/blob/main/docs/ADR-8-Use-RIO-in-cardano%E2%80%90cli.md) |
| 7 | + |
| 8 | +# Context |
| 9 | + |
| 10 | +Currently, errors in `cardano-api` are instances of the class `Error`, which only requires one method: |
| 11 | + |
| 12 | +```haskell |
| 13 | +class Error e where |
| 14 | + prettyError :: e -> Doc ann |
| 15 | +``` |
| 16 | + |
| 17 | +In `cardano-api`, each error is typically specific to a function, and the way errors are aggregated is by wrapping errors within other errors. |
| 18 | + |
| 19 | +The issue with wrapping is that finding out the original source of an error is a lot of work. |
| 20 | +Because it requires following the execution path manually, and tracking where each constructor is applied. |
| 21 | + |
| 22 | +It would be more convenient to have a stack-trace, that points to the offending line and their callers. |
| 23 | +This would allow to find the exact points in the code where the issue happened. |
| 24 | + |
| 25 | +Because we are using `Error` almost everywhere. |
| 26 | +It would make most sense to modify it to ensure that it provides a stack-trace. |
| 27 | + |
| 28 | +There is a known constraint for providing stack-traces in Haskell (`HasStackTrace`). |
| 29 | +It would be ideal to just modify the definition of `Error` to require `HasStackTrace`. |
| 30 | +But this doesn't work, because it can only be applied to functions. |
| 31 | + |
| 32 | +# Proposal |
| 33 | + |
| 34 | +As a work-around, we propose adding a requirement for an error to be able to provide a value of the type `CallStack`. |
| 35 | +This is not a guarantee, that the `CallStack` provided corresponds to the exact place where the `Error` was crafted, but we can leave that to the good will of the developer. |
| 36 | +But it does guarantee that the developer won't forget to add a `CallStack`. |
| 37 | + |
| 38 | +```haskell |
| 39 | +class Error e where |
| 40 | + prettyError :: e -> Doc ann |
| 41 | + getErrorCallStack :: e -> CallStack |
| 42 | +``` |
| 43 | + |
| 44 | +We could leave it there but, ideally, we would like every error to include the stack-trace when pretty printing it. |
| 45 | +So we can do it once, by separating the actual error from our extended `Error` type. |
| 46 | +So we can rename the old `Error` as `ErrorContent`. |
| 47 | +Which could be a class just like this: |
| 48 | + |
| 49 | +```haskell |
| 50 | +class ErrorContent e where |
| 51 | + prettyErrorContent :: e -> Doc ann |
| 52 | +``` |
| 53 | + |
| 54 | +And then we could define our extended `Error` as: |
| 55 | + |
| 56 | +```haskell |
| 57 | +data Content where |
| 58 | + Content :: ErrorContent e => e -> Content |
| 59 | + |
| 60 | +class Error e where |
| 61 | + getErrorContent :: e -> Content |
| 62 | + getErrorCallStack :: e -> CallStack |
| 63 | +``` |
| 64 | + |
| 65 | +We need a `Content` wrapper as a `GADT`, to prevent the type of the `ErrorContent` from propagating to the type of `Error`, while ensuring that it is indeed an `ErrorContent`. |
| 66 | + |
| 67 | +If we do this, we could define two generic functions for every `Error e`. |
| 68 | + |
| 69 | +One to print the original error, without a stack-trace: |
| 70 | + |
| 71 | +```haskell |
| 72 | +prettyErrorWithoutStack :: Error e => e -> Doc ann |
| 73 | +prettyErrorWithoutStack e = |
| 74 | + case getErrorContent e of |
| 75 | + Content content -> prettyErrorContent content |
| 76 | +``` |
| 77 | + |
| 78 | +And one to print the extended error, with the stack-trace: |
| 79 | + |
| 80 | +```haskell |
| 81 | +prettyError :: Error e => e -> Doc ann |
| 82 | +prettyError e = |
| 83 | + vsep |
| 84 | + [ prettyErrorWithoutStack e |
| 85 | + , "Call stack:\n" |
| 86 | + , nest 2 $ pretty $ prettyCallStack (getErrorCallStack e) |
| 87 | + ] |
| 88 | +``` |
| 89 | + |
| 90 | +## Going deeper |
| 91 | + |
| 92 | +Unfortunately, this will only get us a stack trace for the outmost wrapper error. |
| 93 | +And the inner error, which is the most important one, will remain hidden. |
| 94 | + |
| 95 | +One easy solution is to replace the call to `prettyErrorWithoutStack` in `prettyError` with a call to itself (`prettyError`), and ensuring that the error pretty printed by each `ErrorContent e` in its `prettyErrorContent` function recursively calls `prettyError`. |
| 96 | + |
| 97 | +That will make sure that all `Error`s in the stack will have their chance to print their stack-trace. |
| 98 | +And we could nest them for better readability. |
| 99 | + |
| 100 | +But this is not ideal becuase they would be shown like this: |
| 101 | + |
| 102 | +``` |
| 103 | + Error A: The function failed because: |
| 104 | + Error B: The function failed because: |
| 105 | + ... |
| 106 | + Stack trace for Error B |
| 107 | + Stack trace for Error A |
| 108 | +``` |
| 109 | + |
| 110 | +Which pushes the stack trace as far as possible form the description of the error. |
| 111 | + |
| 112 | +While it would be much better like this: |
| 113 | + |
| 114 | +``` |
| 115 | + Error A: The function failed because: |
| 116 | + Stack trace for Error A |
| 117 | + Caused by: |
| 118 | + Error B: The function failed because: |
| 119 | + Stack trace for Error B |
| 120 | + Caused by: |
| 121 | + ... |
| 122 | +``` |
| 123 | + |
| 124 | +And we can achieve that by extending our class `Error` with yet another function (`getCause`). |
| 125 | +This is how it would look like: |
| 126 | + |
| 127 | +```haskell |
| 128 | +class Error e where |
| 129 | + getErrorContent :: e -> Content |
| 130 | + getErrorCallStack :: e -> CallStack |
| 131 | + getCause :: e -> Maybe Cause |
| 132 | +``` |
| 133 | + |
| 134 | +Where `Cause` is another wrapper like `Content`: |
| 135 | + |
| 136 | +```haskell |
| 137 | +data Cause where |
| 138 | + Cause :: Error c => c -> Cause |
| 139 | +``` |
| 140 | + |
| 141 | +And that would allow us to define `prettyError` as follows: |
| 142 | + |
| 143 | +```haskell |
| 144 | +prettyError :: Error e => e -> Doc ann |
| 145 | +prettyError e = |
| 146 | + vsep |
| 147 | + [ prettyErrorWithoutStack e |
| 148 | + , "Call stack:\n" |
| 149 | + , nest 2 $ pretty $ prettyCallStack (getErrorCallStack e) |
| 150 | + , case getCause e of |
| 151 | + Nothing -> mempty |
| 152 | + Just (Cause cause) -> |
| 153 | + vsep |
| 154 | + [ "Caused by:" |
| 155 | + , nest 2 $ "Caused by:" <+> prettyError cause |
| 156 | + ] |
| 157 | + ] |
| 158 | +``` |
| 159 | + |
| 160 | +This allows us to keep the error structures as they are, while annotating them with their stack-traces at each point. |
| 161 | + |
| 162 | +# Consequences |
| 163 | + |
| 164 | +The tricky bit is updating the existing code to comply with the new instance. |
| 165 | + |
| 166 | +The types need to be extended someway to allocate for the stack-trace, and there is no clean way around it. |
| 167 | +It may be possible to do this in a more or less transparent way by using `Deriving` and/or `Template Haskell`. |
| 168 | +But, other than that, it comes down to adding a space for the stack-trace in the types that implement the `Error` instance. |
| 169 | + |
| 170 | +At least, we can have a reusable wrapper for errors (that must now implement the `ErrorContent` class). |
| 171 | + |
| 172 | +So we can define an `ErrorWithStack` data type using a `GADT` as follows: |
| 173 | + |
| 174 | +```haskell |
| 175 | +data ErrorWithStack e where |
| 176 | + RootErrorWithStack :: ErrorContent e => e -> CallStack -> ErrorWithStack e |
| 177 | + CausedErrorWithStack :: (ErrorContent e, Error c) => e -> CallStack -> c -> ErrorWithStack e |
| 178 | +``` |
| 179 | + |
| 180 | +The difference between the constructors is that one has a causing error, and the otherone doesn't. |
| 181 | + |
| 182 | +We can then create convenience functions for creating them from existing `ErrorContent`s: |
| 183 | + |
| 184 | +```haskell |
| 185 | +mkError :: (ErrorContent e, HasCallStack) => e -> ErrorWithStack e |
| 186 | +mkError e = RootErrorWithStack e callStack |
| 187 | + |
| 188 | +mkErrorWithCause :: (ErrorContent e, Error c, HasCallStack) => e -> c -> ErrorWithStack e |
| 189 | +mkErrorWithCause e = CausedErrorWithStack e callStack |
| 190 | +``` |
| 191 | + |
| 192 | +And we can trivially derive an `Error` instance for `ErrorWithStack`: |
| 193 | + |
| 194 | +```haskell |
| 195 | +instance ErrorContent e => Error (ErrorWithStack e) where |
| 196 | + getErrorContent :: ErrorWithStack e -> Content |
| 197 | + getErrorContent (RootErrorWithStack e _) = Content e |
| 198 | + getErrorContent (CausedErrorWithStack e _ _) = Content e |
| 199 | + |
| 200 | + getErrorCallStack :: ErrorWithStack e -> CallStack |
| 201 | + getErrorCallStack (RootErrorWithStack _ cs) = cs |
| 202 | + getErrorCallStack (CausedErrorWithStack _ cs _) = cs |
| 203 | + |
| 204 | + getCause :: ErrorWithStack e -> Maybe Cause |
| 205 | + getCause (RootErrorWithStack _ _) = Nothing |
| 206 | + getCause (CausedErrorWithStack _ _ c) = Just $ Cause c |
| 207 | +``` |
| 208 | + |
| 209 | +## Adapting existing code |
| 210 | + |
| 211 | +Adapting existing code does require modifying the existing code quite a bit in principle, and it is not always obvious how. |
| 212 | + |
| 213 | +For example, we can adapt the following error: |
| 214 | + |
| 215 | +```haskell |
| 216 | +data ProtocolParametersConversionError |
| 217 | + = PpceOutOfBounds !ProtocolParameterName !Rational |
| 218 | + | PpceVersionInvalid !ProtocolParameterVersion |
| 219 | + | PpceInvalidCostModel !CostModel !CostModelApplyError |
| 220 | + | PpceMissingParameter !ProtocolParameterName |
| 221 | + deriving (Eq, Show, Data) |
| 222 | + |
| 223 | +instance Error ProtocolParametersConversionError where |
| 224 | + prettyError = \case |
| 225 | + PpceOutOfBounds name r -> |
| 226 | + "Value for '" <> pretty name <> "' is outside of bounds: " <> pretty (fromRational r :: Double) |
| 227 | + PpceVersionInvalid majorProtVer -> |
| 228 | + "Major protocol version is invalid: " <> pretty majorProtVer |
| 229 | + PpceInvalidCostModel cm err -> |
| 230 | + "Invalid cost model: " <> pretty @Text (display err) <> " Cost model: " <> pshow cm |
| 231 | + PpceMissingParameter name -> |
| 232 | + "Missing parameter: " <> pretty name |
| 233 | +``` |
| 234 | + |
| 235 | +By first renaming it as `ProtocolParametersConversionErrorContent`, changing the instance to `ErrorContent` and the function implemented to `prettyErrorContent`, and by creating a type synonym for `ErrorWithStack ProtocolParametersConversionErrorContent` for convenience: |
| 236 | + |
| 237 | +``` |
| 238 | +data ProtocolParametersConversionErrorContent |
| 239 | + = PpceOutOfBounds !ProtocolParameterName !Rational |
| 240 | + | PpceVersionInvalid !ProtocolParameterVersion |
| 241 | + | PpceInvalidCostModel !CostModel !CostModelApplyError |
| 242 | + | PpceMissingParameter !ProtocolParameterName |
| 243 | + deriving (Eq, Show, Data) |
| 244 | +
|
| 245 | +instance ErrorContent ProtocolParametersConversionErrorContent where |
| 246 | + prettyErrorContent = \case |
| 247 | + PpceOutOfBounds name r -> |
| 248 | + "Value for '" <> pretty name <> "' is outside of bounds: " <> pretty (fromRational r :: Double) |
| 249 | + PpceVersionInvalid majorProtVer -> |
| 250 | + "Major protocol version is invalid: " <> pretty majorProtVer |
| 251 | + PpceInvalidCostModel cm err -> |
| 252 | + "Invalid cost model: " <> pretty @Text (display err) <> " Cost model: " <> pshow cm |
| 253 | + PpceMissingParameter name -> |
| 254 | + "Missing parameter: " <> pretty name |
| 255 | +
|
| 256 | +type ProtocolParametersConversionError = ErrorWithStack ProtocolParametersConversionErrorContent |
| 257 | +``` |
| 258 | + |
| 259 | +We can then just resolve type errors that appear at those places where `ProtocolParametersConversionErrorContent` is created by appending `mkError` before. |
| 260 | +For example, we get an error here: |
| 261 | + |
| 262 | +```haskell |
| 263 | +mkProtVer :: (Natural, Natural) -> Either ProtocolParametersConversionError Ledger.ProtVer |
| 264 | +mkProtVer (majorProtVer, minorProtVer) = |
| 265 | + maybeToRight (PpceVersionInvalid majorProtVer) $ |
| 266 | + (`Ledger.ProtVer` minorProtVer) <$> Ledger.mkVersion majorProtVer |
| 267 | +``` |
| 268 | + |
| 269 | +And after fixing it we would get: |
| 270 | +```haskell |
| 271 | +mkProtVer :: (Natural, Natural) -> Either ProtocolParametersConversionError Ledger.ProtVer |
| 272 | +mkProtVer (majorProtVer, minorProtVer) = |
| 273 | + maybeToRight (mkError $ PpceVersionInvalid majorProtVer) $ |
| 274 | + (`Ledger.ProtVer` minorProtVer) <$> Ledger.mkVersion majorProtVer |
| 275 | +``` |
| 276 | + |
| 277 | +Note that we have added `mkError` before the `PpceVersionInvalid` constructor. |
| 278 | + |
| 279 | + |
| 280 | +## Errors within errors |
| 281 | + |
| 282 | +In the previous example, the errors inside `ProtocolParametersConversionError` were all defined outside of the package, so we left them untouched. |
| 283 | +But when dealing with errors within errors within inside `cardano-api`, we would remove the recursive call from the pretty-printer of the error, and we would pass the inner error to `mkTestWithCause` instead. |
| 284 | + |
| 285 | +We can still keep the nested `ErrorContent` if we want, to be able to access it from code that may handle the error. |
| 286 | +It won't consume any additional space, since Haskell treats it is as a reference. |
| 287 | + |
| 288 | +We can also inspect `ErrorContent` inside `Error`s by just using the `getErrorContent` function. |
| 289 | + |
| 290 | +# Conclusion |
| 291 | + |
| 292 | +The proposed approach ensures every error in `cardano-api` has a stack-trace associated, and it provides a way of displaying all the layers of errors in a readable way, without losing any of the information that is currently available, and altering the existing code very minimally. |
| 293 | + |
| 294 | +On the other hand, it does require a big refactoring, and it complicates the error types a little. |
| 295 | + |
| 296 | + |
0 commit comments