I created a small genserver and it works great but I want to add a timeout to a call, and to test it in the handle_call/3 I am doing Process.sleep/1 with a longer time then the timeout, but instead of getting a :timeout message to catch in handle_info/2 I am just getting an error that says:
** (exit) exited in: GenServer.call(Checksum.Checksum, :calculate, 50)
** (EXIT) time out
here is the call:
def calculate_checksum() do
GenServer.call(__MODULE__, :calculate, 50)
end
here is the handle:
@impl GenServer
def handle_call(:calculate, _, state) do
Process.sleep(60)
result = 0
{:reply, {:ok, result}, state}
end
this is where I would expect to catch the timeout:
@impl GenServer
def handle_info(:timeout, state) do
{:reply, {:error, :timeout}, state}
end
What I actually want to use is to show a different response if the timeout happens.
Now I added the timeout to where you said, and I am getting the :timeout message.
But where I am making the call to the genserver I am still getting the result.
with {:ok, checksum} <- Checksum.calculate_checksum() do
conn
|> put_status(200)
|> put_view(ChecksumView)
|> render("checksum.json", checksum: checksum, message: "Checksum calculated successfully!")
else
_ ->
conn
|> put_status(488)
|> put_view(ChecksumView)
|> render("408.json")
end
As you saw from my handle_call for :calculate I am returning {:ok, result}, but I am also seeing the :timeout message being handled.
@impl GenServer
def handle_info(:timeout, state) do
{:noreply, state}
end
What would be ideal if there is a timeout instead of getting {:ok, result} I could get something like {:error, :timeout} but If I do reply with this error and state in the handle_info then it is a bad return value.
When you do a call with a timeout this is the timeout the caller process will wait before it throws an exception. You need to rescue this exception at the call site.
The timeout in a gen_server (the one you can set in the callback of any handle) is an internal timeout that sets an amount of time the gen_server can be without receiving any message, which upon it elapsing triggers an internal message of :timeout that you can handle. This timeout is disabled whenever a message arrives in the gen_servers mailbox and needs to be set again if you wish it to trigger again.
Now the problem is that your calling process will still receive a message with the response, because the GenServer will send it, as it will have received the call.
So what you can do is wrap your call in a task:
def calculate_checksum() do
Task.async(fn ->
try do
GenServer.call(__MODULE__, :calculate, 15)
catch
:exit, {:timeout, {GenServer, :call, _}} -> {:error, :timeout}
end
end)
|> Task.await()
end
Note that Task.await also has a timeout, and like GenServer.call/3 the default is 5000, You don’t care as you are targetting 15ms here. But you can set it to :infinity it that makes sense.
The task process is linked to the caller, so if you have an error/exit that is not the timeout, it will still propagate to your calling process, no risk of missing errors.
The task will return with the checksum or {:error, :timeout}. If there is a timeout, as I have said the GenServer will still send the response. But it will send it to the task process, which will by dead by then. And sending a message to a non-existent process is fine in Elixir. That is the way to safely ignore the response.
Finally, as you handle a short timeout and ignore the response, keep in mind that the GenServer will still have to calculate the checksum, it will still do the requested work to send the response which will be ignored.
In practice it does not seem like a problem that the calling process receives the response later, because I already sent the response by the time the result would arrive.
Do you think it is still worth it to wrap it in a Task?
I call the calculate_checksum from a with where I expect it to be {:ok, result}, and I have an else in the with where I send a different response if I got the timeout error.
Like this
with {:ok, checksum} <- Checksum.calculate_checksum(String.to_integer(params["timeout"])) do
conn
|> put_status(200)
|> put_view(ChecksumView)
|> render("checksum.json", checksum: checksum, message: "Checksum calculated successfully!")
else
_ ->
conn
|> put_status(408)
|> put_view(ChecksumView)
|> render("408.json")
end