Skip to content

Commit 7edd91a

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

File tree

1 file changed

+296
-0
lines changed

1 file changed

+296
-0
lines changed
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
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

Comments
 (0)