Skip to content

Commit d3766db

Browse files
committed
Add proposal ADR for addressing lack of stack-traces in cardano-api
1 parent 5360329 commit d3766db

File tree

1 file changed

+278
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)