Electron: Getting Custom Error Messages from IPC Main

Michael T. Andemeskel
5 min readMar 20, 2022

In Electron if you want to communicate between the Node process running your app (main) and the Chromium process running your frontend JS (renderer) you will need to use IPC, Inter Process Communication. IPC is very useful for doing many things like making server calls and avoiding CORS and accessing native APIs like file storage and other OS APIs. Best of all, it is both asynchronous and synchronous which makes using it easy but there’s one problem.

IPC And Custom Errors

IPC rejected promises do not handle custom error objects. IPC sends and receives messages as JSON. The error serializer breaks when it encounters any error object that has custom fields. For simple use cases this is not a problem. But if you are using Axios, for example, to make API calls in the main process and Axios throws an 500 error due to a bad server response IPC will not convey that error. This is due to an issue with how V8 serializes error objects — V8 is the engine that runs JS in Chromium. V8 throws an error and there is nothing that catches that error so the entire IPC promise to be rejected. This is a pain because you can’t simply reject the IPC call and pass the error you want. It forces you to use standard errors or translate custom errors to standard ones.

Rejecting an IPC call will send a generic IPC error, UnhandledPromiseRejectionWarning, to the receiver which is confusing since the real error has nothing to do with IPC but with Axios. This error is thrown when a promise is rejected and there is no catch clause to handle the rejection. So, when the Axios call fails, you are forced to catch the error and send it in a resolved call in order to maintain the data in the error. This makes it seem like the Axios call was successful because the promise was resolved not rejected. You’ll need a separate code path to handle these false resolutions. This is a mess.

My Work Around

The work around I devised was to create a thin abstraction around the IPC calls on the frontend and minor changes to the backend. I make calls to the main process using this abstraction and this abstraction receives the responses from the main process.

On the main process, I catch all errors and package them into a resolved message which I add a flag to handle_as_rejected_promise which is set to true. This flag instructs the abstraction layer to reject the promise once it gets the message. Since the promise is resolved I can pass it any error object without running into parsing issues. The abstraction layer on the frontend transforms the resolved promise into a rejected one and passes it the error. My frontend gets a rejected promise with the correct error. This worked very well and I adopted it to all my IPC calls.

Advantages

  • all promises are resolved, so you can pass custom error objects to the renderer and avoid confusing IPC errors
  • simple and easy interface to understand and maintain
  • allows you to handle IPC like regular async calls, don’t have to create separate code paths for IPC errors
  • fast and complete, this solution does not require a priori knowledge of what errors will occur — it handles expected and unexpected errors without any code changes
  • BONUS, makes testing easier, the abstraction layer is easier to mock than the IPC module

Disadvantages

  • one more piece of code you gotta write, test, and maintain
  • overloading promise resolved can cause minor confusion if not documented

Other Work Arounds

There are other attempts at solving this, most of them documented in the GitHub issue. Some of these solutions involve transforming custom errors into a format IPC handle (a string or existing error object) before sending the error. This requires knowing all the runtime errors you will encounter before hand which in my decade of dealing with JS is not worth your time. Ultimately, this issue has to be solved at the root cause, V8 needs a patch.

Renderer Code — The Abstraction

Here is a JS version of the abstraction and TypeScript version can be found here.

/**
* This exists because `ipcMain.handle` does not allow
* you to return rejected Promises with custom data.
* You can throw an error in `handle` but it can only
* be a string or existing error object. This means all
* the error processing logic must live in main process
* in order to figure what string or error type to throw
* in `handle`.
*
* This abstraction allows us to send messages to `handle`.
* If `handle` resolves to message with `rejected` equal
* to true then this method throws an error with the object
* that is contained in the resolved promise. Everything else
* is the same.
*
* This allows us to get custom error objects and use catch
* in `ipc.invoke` calls. It also frees us to remove all the
* error catching from `then` - since all failures will be
* caught in ipcMain and re-thrown here.
*
* https://github.com/electron/electron/issues/24427
* https://github.com/electron/electron/issues/25196
*/
import { ipcRenderer } from 'electron'
const invoke = async (channel, args) => {
try {
const response = await ipcRenderer.invoke(channel, args)
if (isRejectedPromise(response))
throw response
else
return response
} catch (error) { console.warn(
'[IpcRendererService.invoke] threw an error',
{ error },
)
return new Promise((_, reject) => reject(error))
}
}
const isRejectedPromise = response_from_ipc_main => {
if (response_from_ipc_main === undefined)
return false
else if (response_from_ipc_main.handle_as_rejected_promise)
return true
else
return false
}
export default {
invoke
}

Renderer Code — Using the Abstraction

import IpcRendererService from '../services/ipc_renderer_service'
IpcRendererService.invoke('check-for-app-updates').then(
this.handleCheckForAppUpdateResponse
).catch(
this.handleCheckForAppUpdateError
).finally(
() => progressbar.hide()
)

Main/Background.js Code

const checkForUpdates = async () => {
try {
const result = await autoUpdater.checkForUpdatesAndNotify()
return result.updateInfo
} catch (error) { console.log('checkForUpdates, failed', error)
return {
error,
handle_as_rejected_promise: true,
}
}
}
ipcMain.handle('check-for-updates', checkForUpdates)

Sources

--

--

Michael T. Andemeskel

I write code and occasionally, bad poetry. Thankfully, my code isn’t as bad as my poetry.