tauri
tauri copied to clipboard
Feature request: JS API throws Error objects instead of strings
Is your feature request related to a problem? Please describe. As a web developer who works primarily in with HTML/CSS/JS, all of my app code is currently JS which calls the tauri APIs via its JS bindings when appropriate. When an expected exception occurs (for example, trying to read a file that doesn't exist), I'd like to catch it and do something depending on the type of the exception. The JS bindings currently always throw string error messages instead of objects that extend Error, meaning that code for handling different kinds of exceptions involves messily parsing the error string or creating custom commands in Rust.
Describe the solution you'd like I would like JS API calls to raise error objects that are associated with the expected kind of exception. They would provide access to the error message string plus any extra data relevant to that specific kind of exception. It would reduce the friction of implementing good exception handling as app developers wouldn't need to come up with custom exception parsing/wrapping solutions.
Describe alternatives you've considered If it were not part of the API, somebody would likely create a separate library to do it at some point. It'd be better if it were just part of the API contract itself though.
I totally understand where this comes from, and while I don't think you are wrong - I would encourage you to think from the perspective of "nothing being an error".
I think that the whole try / catch methodology of trapping exceptions is actual a relic of the days before you had strongly typed metalanguages like typescript and you wouldn't have to bother being diligent about how and what can be returned. Not finding a file shouldn't trigger an exception, it should be expected behaviour that is handled properly.
One of the guiding principles that Tauri seeks to embrace is that of the Actor Model's message passing. This means that different actors pass messages to each other, maintain their own internal state, and have their own decision tree. (In this case the string responses from the rust backend.) The moment rust starts to throw interrupts around, it ceases to be an actor model system and (from my perspective) actually enters very unsafe territory. That is because the rust side of the equation does not track or really know anything about the state of the UI in the webview. It can very obviously control it - but should it? Where does the idempotency lie?
Anyway, I think that my perspective is more a philosophical one - and I understand how this classical exception-handling approach as control structure may be useful for some kinds of javascript development, but I am not convinced that it is a tight fit for the API...
I'm not sure I follow, the JS API currently does raise exceptions for the cases when something other than a success happens. It's just that since they're always strings, it's hard to disambiguate the different cases and perform branching decisions based on them. If you'd prefer that the JS API never raise exceptions but instead always return result objects to avoid try/catch blocks, I'd have no problem with that either.
My argument is just that whatever you choose, each JS API response needs to be easily differentiable so that it can be branched on without string parsing. Dropping into Rust to write custom commands for all business logic isn't a great solution either since A) frontend devs tend to be more comfortable in JS and prefer to stay in it if possible and B) it kind of defeats the purpose of having JS API bindings to begin with if you're always needing to circumvent them to handle different cases robustly.
I totally understand where this comes from, and while I don't think you are wrong - I would encourage you to think from the perspective of "nothing being an error".
I think that the whole try / catch methodology of trapping exceptions is actual a relic of the days before you had strongly typed metalanguages like typescript and you wouldn't have to bother being diligent about how and what can be returned. Not finding a file shouldn't trigger an exception, it should be expected behaviour that is handled properly.
What was being noted here was that not finding a file does trigger an exception, except instead of throwing an object with useful metadata for differentiating not finding a file from not having permissions to access it (or the file being badly encoded, or the file being locked, etc) what is thrown is a string and contains no easily accessible information on the cause of failure.
One of the guiding principles that Tauri seeks to embrace is that of the Actor Model's message passing. This means that different actors pass messages to each other, maintain their own internal state, and have their own decision tree. (In this case the string responses from the rust backend.) The moment rust starts to throw interrupts around, it ceases to be an actor model system and (from my perspective) actually enters very unsafe territory. That is because the rust side of the equation does not track or really know anything about the state of the UI in the webview. It can very obviously control it - but should it? Where does the idempotency lie?
This isn't particularly relevant, I think? While the Rust side certainly shouldn't interfere directly with the operation of the JS side, the issue at hand is the Rust backend returning lackluster failure results (safely and normally via RPC) which are then being handled by the JS side (by throwing them, unprocessed). Building out string-based parsing on the JS side when the RPC fully supports serialized results is obviously not a great choice, so the Rust backend should return some more robust serialized failure results which can be handled by JS as you see fit. The Rust side itself is not actually interfering in this way and would not ever be were this issue to be addressed as written - the two continue to only interface via message passing regardless.
I should note that I'm not arguing for the JS side of the API receiving details of errors with the Rust side's logic. Rather, it is delegating operations to Rust and should receive detailed and especially actionable reports of the cause of failure where those operations fail, particularly in situations where failure should be handled safely.
Anyway, I think that my perspective is more a philosophical one - and I understand how this classical exception-handling approach as control structure may be useful for some kinds of javascript development, but I am not convinced that it is a tight fit for the API...
As for this: the Tauri API here is referring to a system built to be consumed by JS and as such it should follow JS conventions rather than being opinionated. If users want a different behavioural pattern out of it, robust tools will already exist to convert conventional behaviour to this different pattern, whereas those tools likely will not exist if Tauri implements its own error handling patterns and a user prefers something else. So I'd suggest that the conventional JS pattern (flawed as it may be) is the best fit for the API, simply by the context in which the API is used.
In the interest of being constructive, a suggestion on how I believe error handling should work with RPC calls and thus the internals of the API as a whole:
- The RPC results Rust returns to JS for any invocation via RPC should have a wrapper object with an indication of whether the operation succeeded or failed. This can be automatically populated based on the return types of the Rust function call -
Result<T, E>is obvious, though shouldOption<T>resolve to a failure onNoneor a success with an empty/null value? At any rate, any function call being hooked up to the RPC interface should only require that return types are fullySerializeto allow for this behaviour to be automated, and this goes for user-definedtauri::commands as well - no manual interaction with the RPC should be required. - On the JS side, where the RPC result indicates success, simply resolve the promise with the contained value.
- Where the RPC result indicates failure, generate a
RPCFailureobject with the details and throw/reject it. The API method itself can then catch this error and use it to generate more specific errors, using the metadata attached. So a successful result forfs.readTextFile("/home/hello/Documents/Nothing.md")might be:
{ "ok": true, "v": "# Nothing\n\nNothing is in this file, actually.\n" }
And a failure result might be:
{ "ok": false, "v": { "code": 2, "message": "The system cannot find the file specified.", "filename": "/home/hello/Documents/Nothing.md" } }
Which would then generate a RPCFailure object with that data attached. This is thrown, and the fs.readTextFile method handles it and uses it to generate its own error (which is then thrown).
- The reason for the reconstruction here is to ensure as pertinent as possible a stack trace - the error object the user sees is constructed directly in the invoked method, rather than containing the whole chain of anonymous callbacks,
transformCallback,invokeTauriCommand,invoke, etc. - This also allows errors to be as specific as possible without ever offloading any responsibility for that onto the Rust side of the boundary. The downside here is that bridging code does need to be written to handle
RPCFailures and convert them to specific error objects. - The
RPCFailureis thrown rather than returned to preserve the mapping of Rust/JS failure conventions for users writing their owntauri::commands and accompanying bridging code.
Ultimately, for both users creating their own commands and Tauri maintainers writing the API itself, there is an idiomatic way to both output failure results (Result/Option in Rust) and to receive failure results (thrown RPCFailures in JS) being bridged by the RPC code on either side, which can remain entirely opaque. Whatever information is returned from Rust code is already deserialized and ready for use on the JS side. This also preserves the actor model by ensuring that Rust knows nothing more about JS error handling than informing it as to whether the operation succeeded or not.
Obviously, my intent here isn't to dictate this is how it must be, rather to try to foster some discussion as to how this should be as the current situation is clearly a placeholder (or if not, is extremely worrying).