Replies: 6 comments 15 replies
-
not quite - there are two fundamental differences:
I'll also note that annotating all code in dagger with |
Beta Was this translation helpful? Give feedback.
-
Firstly, thanks so much for putting this in a discussion, as it will allow others to weigh in that have not been privy to our internal discussions.
I couldn't agree with this more! I, too, am not married to exceptions nor results, and am keen to get the best possible solution implemented. I spent quite a lot of time migrating exceptions in an existing nim-status codebase using The first thing to address is that the discussion above centers around the idea that the only difference is syntax and performance:
When we moved to Results across the entire codebase in nim-status, Results coerced the codebase to consider the architectural layers of the application by way of defining Error types: each layer would have its own Error type. The reason behind this is that the returned errors needed to be considered from the viewpoint of the consumer. For example if we encounter an IO error, eg file not found, we don't necessarily want to bubble that up to the consuming layer as it doesn't quite make sense in the context of the consumer. A consumer should expect an error that is relevant to the layer it called. For example, if we have a database layer, which exposes an api, eg
Respectfully disagreeing, once we start handling errors using results, however, we've already done quite a lot of reasoning that the consumer likely won't need to inspect the error stack to reason any further. As above, sometimes the underlying error doesn't make sense when bubbled up, and instead all we need to do as a consumer is reason about the error we've received from the layer below. Additionally, when mapping the error ( ReadabilityAs a reader, when trying to reason where exception originated in a chunk of code is nearly impossible without inspecting each and every call inside the block. Take, for example, a snippet from try:
while (
let chunk = await chunker.getBytes();
chunk.len > 0):
trace "Got data from stream", len = chunk.len
without blk =? bt.Block.init(chunk):
return failure("Unable to init block from chunk!")
blockManifest.put(blk.cid)
if not (await node.blockStore.putBlock(blk)):
# trace "Unable to store block", cid = blk.cid
return failure("Unable to store block " & $blk.cid)
except CancelledError as exc:
raise exc
except CatchableError as exc:
return failure(exc.msg)
finally:
await stream.close() How can we know where Downside of ResultAs mentioned, there is an ergonomic annoyance with Result that can get in the way. For example, catching simple exceptions like {.push raises: [Defect].}
proc getPath(newIdx: int): string =
?(catch fmt"{PATH_WALLET_ROOT}/{newIdx}").mapErrTo(PathFormatError) However, {.push raises: [Defect].}
proc getPath(newIdx: int): string {.raises: [ValueError].} =
fmt"{PATH_WALLET_ROOT}/{newIdx}") But that pushes the problem up the call chain. Even if it's pushed up the call chain, we now at least know explicitly that we have a proc (or procs if pushed up the chain) that could potentially raise |
Beta Was this translation helpful? Give feedback.
-
Agreed. I think we can live with Exception or Result, as long as we're consistent. The sooner we make a well-informed and well-reasoned decision, the better, in my opinion (seems we're very close). And then we just need to stick to it. That being said, I tend to prefer
Those are my personal impressions: feel free to disagree strongly. As Eric mentioned earlier, he and I put our heads together yesterday and came up with some ideas. Below I'll show what I have in mind. Note that First, let's wrap around stew/results' import chronos except `?`
import stew/results except `?`
template `?`*[T, E](self: Future[Result[T, E]]): auto =
# h/t @emizzle 🎉
when not declared(chronosInternalRetFuture):
{.fatal: "can only be used within the body of a chronos {.async.} proc!".}
let v = (self)
var rv = await v
if rv.isErr:
when typeof(result) is typeof(rv):
chronosInternalRetFuture.complete(rv)
else:
when E is void:
chronosInternalRetFuture.complete(err(typeof(rv)))
else:
chronosInternalRetFuture.complete(err(typeof(rv), rv.error))
return chronosInternalRetFuture
when not(T is void):
rv.get
template `?`*[T, E](self: Result[T, E]): auto =
let v = (self)
when declared(chronosInternalRetFuture):
let av = proc(): Future[Result[T, E]] {.async.} = return v
? av()
else:
results.`?` v Those templates could be provided by e.g. (hypothetical) I've been experimenting with them: so far they seem to work fine. If I've missed something important, i.e. they won't work robustly, I'm all ears. Some error kinds: type
DiscoveryErrors {.pure.} = enum
Default = "Discovery error"
NoPeersFound = "No peers found"
Other = "Some other discovery error"
Timeout = "Discovery timed out"
DownloadErrors {.pure.} = enum
Default = "Download error"
DiscoveryFailed = "Peer discovery failed"
Other = "Some other download error" Some error types: type
PolyError[T] = object of CatchableError
kind*: T
DiscoveryError = PolyError[DiscoveryErrors]
DownloadError = PolyError[DownloadErrors] Here's the original Result example refactored to use async friendly proc findPeers(self: Store, id: Cid, timeout: uint64): Future[Result[seq[PeerInfo], DiscoveryError]] {.async.} =
...
proc findPeersRetry(self: Store, id: Cid, retries: Natural = 0): Future[Result[seq[PeerInfo], DiscoveryError]] {.async.} =
var remaining = retries
while true:
let res = await self.findPeers(id)
if res.isOk or res.error.kind != Timeout:
return res
elif remaining > 0:
dec remaining
trace "Couldn't find peers, retrying", retry=(retries - remaining)
continue
else:
break
return autoErr(NoPeersFound)
proc download(self: Node, id: Cid): Future[Result[BufferStream, DownloadError]] {.async.} =
if id notin self.store:
let peers = ? findPeersRetry(5).autoErr(DiscoveryFailed)
peers.mapIt(node.switch.connect(it))
let stream = BufferStream.new()
...
return stream This is just to get the idea. How many and what enums and error types are warranted (or whether to use a different approach) is where the careful thought process comes in. The idea with My example above hints at
That is to say, I'm optimistic re: the above... I think it will work out... I'm still experimenting. Regardless of whether my idea for Footnotes
|
Beta Was this translation helpful? Give feedback.
-
Ok, here are my 2 cents: I think, as discussed here and elsewhere, exceptions and results are very similar, more than most would admit. After all, chronos is wrapping exception in result, showing that they are almost interchangeable. So, I want to focus on the actual differences at usage, if we forget for a second the current async issues, performance, and what could be modded easily (for instance as demonstrated by @emizzle and @michaelsbradleyjr) (goal here is not to say which is better, or which I like best, but to try to make an honest and complete comparison to be able to make a decision) Operation chainingEg: var something
try:
something = canFail1().canFail2()
if condition: something &= canFail3()
except SomeError: something = cannotFail()
except OtherError: ditto Results: you have to lift everything and have compatible error types. Compatible error re-raisingEg: proc r: Result[void, AnError] =
? procFailingWithAnError()
proc e {.raises: [AnError].} =
procRaisingAnError() #hidden re-raise Exceptions allows you to be implicit, results forces you to be explicit (with Possible error typesResults allows you to use 1 error type, while raises allow as many as you want. Note that is easily circumvented in result by creating an error type which maps to multiple possible errors Error typeException: has to inherit from CatchableError Multiple errorsEg proc a(i: seq[input]): seq[Result[output, AnError]] Result: can be done fairly easily (you loose some syntactic sugar) Syntax (keywords actually)(? vs try, mapError vs except, etc) ConclusionFirst of all, am I missing something?
|
Beta Was this translation helpful? Give feedback.
-
Seems to work for Go, which quickly became the most solid language for writing networking software, despite this increase in verbosity: https://go.dev/blog/error-handling-and-go So do we care more about pretty function call chaining, or robustness? I'd go further and discourage the use of "?" because it's unreadable when parsing code quickly and it can get dangerous by hiding an early function exit: https://gist.github.com/stefantalpalaru/d96430de60305ce54604d9cf0e601ec5 Verbosity helps comprehension, here. The "Result" API also got overly large and complex, increasing the cognitive load. Do we really need more than Yes, it was annoying to handle all those possible errors, but the software is better for it. |
Beta Was this translation helpful? Give feedback.
-
When it's needed, it's needed, and it's better if it's also visible.
That's usually just a way to kick the can down the road and, for that, it's even easier if it's all done under the hood. The end result will rarely be robust software, though. |
Beta Was this translation helpful? Give feedback.
-
After implementing #32 and experimenting a little bit with
Result
andquestionable
, I wanted to leave my current impressions and gather some feedback around whats the best way forward.Our current situation is pretty dire, inadvertently we've ended up with two half working error handling mechanisms - exceptions which don't support
raises
with async code and result which is also partially broken with async, specially?
doesn't work in async transformations. Above all, we've ended up with an inconsistent implementation which is what worries me the most at this point - consistency is key when it comes to error handling and it's the one things we aren't right now.The debate around
Exception
vsResult
tends to focus on safety, performance and ergonomics. I want to give my perspective on each of this points, but bare in mind that this are nuanced topics and it's easy to be subjective when treating them, so don't take this as an attempt to "convert" anyone, in fact I'm not dead set on anything at all, this is just my current perspective and I really do want to understand what's the best way of writing software in our specific case.With that said, it's probably no secret that so far I have been unconvinced by the supposed advantages of
Result
overException
. Practically, exceptions are less secure because of historical reasons - Nim bugs, lack of integration with async, etc... However, they have been or are in the process of being resolved.Assuming that exceptions work as promised and we can eventually use
raises
consistently across sync and async code, I don't see any advantage ofResult
overException
. Semantically and by extension safety wise, they are about the same!The difference is then in the syntax (i.e. ergonomics) and performance.
Performance wise, exceptions might be a tad more expensive, but the interesting thing is that the performance penalty comes from the way exceptions have traditionally been implemented, but it doesn't have to be like that. In fact, it's possible to implement exceptions on top of
Results
and this is what some languages like Haskel and Swift have done.At this point, I treat the performance overhead as an implementation detail and not something inherent to the exceptions mechanism proper. It's also worth asking wether the supposed overhead is of any significance to the type of software we're writing? I'm inclined to say that overwhelmingly it isn't.
Let me now move on to my major beef with
Result
- ergonomics. IMO,Result
sacrifices ergonomics for all the wrong reasons. In part this is because of howResult
is historically used, i.e. this isn't inherent toResult
, in part it is.One issues which I and others bring up is chaining (a().b().c()) -
Result
complicates this. Surprisingly (or unsurprisingly), this isn't inherent toResult
itself, rather the fact that traditionally it's used with simple types (enums, bools, strings, etc...), the lack of a polymorphic type hierarchy forces the constant use ofmapErr
and similar to map across the different errors. IfResult
is used with a proper error hierarchy, most of this issues go away, similar to howquestionable
does it by relying on the existentCatchableError
hierarchy.Some would argue that this is wrong, but is it? What's better, a hierarchy of polymorphic types that can be propagated up the stack with
?
without any invocation ofmapErr
or endlessly mapping enums to string and vice-versa? To me the former is safer, because there isn't any loss of information or context.This brings me to
?
, which is essentiallyResult
's way of saying "I don't care about the error". The counterargument I've heard here is that "you can't simply return the error with?
without handling it", but IMO this is far from true,?
/mapErr
combo is the usual escape hatch, albeit with a lot more ceremony and loss of information in the process.What happens if
?
is gone and you're left with the bareResult
api? Well, in 99% cases you'll end up emulating?
one way or another, just because writing an if/else every other line is nuts!So back to my initial argument of
Result
and checkedExceptions
being equivalent - I strongly believe thatResult
brings little to the table (if at all) at the expense of worst overall UX and safety.Let me illustrate:
Exception:
Result:
Note that I haven't compiled this and admittedly this example is a bit contrived and doesn't attempt to be correct as far as the actual implementation goes, but it is close to whatever we end up with.
In a few words on what the code is attempting to do:
findPeers
should discover some peers or return the ones we already know of from our routing table based on the passedid
. If we couldn't find/connect to any within some timeout, raise or return an error (we could also pass a minimum number of peers we want, but it's out of scope for this example).download
will attempt to callfindPeers
repeatedly untilretry == 0
or it succeeds finding peers within the retry limit, it will then attempt to request the block from thestore
, which should dispatch to the network accordingly, etc...From the two examples above, the number of lines is actually pretty much the same, what strikes me however is the awkward interleaving of error handling and the execution flow in the results example. IMO, being able to readily understand the intent of the entire sequence is more valuable to the reader than attempting to decipher what type of errors are being handled in the process. The exceptions based example is IMO much easier to follow at a glance than the result one.
Does this make
Exception
fundamentally better thanResult
, not necessarily, as I mentioned above, most of the issues come frommapErr
and that can be addressed by an error hierarchy, but I do think that overall, even when used more like exceptions, it's still inferior UX wise.So whats my verdict so far? If we get proper support for
raises
in Chronos with status-im/nim-chronos#251, then I don't see any immediate advantage of usingResult
, even if we manage to properly address?
in async transformations.Beta Was this translation helpful? Give feedback.
All reactions