GenServer call time-outs

- -

There is a GitHub repository that accompanies this post.

By law, every OTP tutorial must include an exercise in which you build your own version of GenServer before the astonishing revelation that OTP provides a better thought-out one. So, you will already know that GenServer.call is implemented something like

def call(server, request, timeout \\ 5_000) do
  ref = Process.monitor(server)
  send(server, {:"$gen_call", {self(), ref}, request})
  receive do
    {^ref, reply} ->
      Process.demonitor(ref)
      reply
  after
    timeout ->
      Process.demonitor(ref)
      exit(:timeout)
  end
end

Of course there's a bit more to it than that, but it's pretty damned close.

Forcing a time-out

In the accompanying code we have a GenServer, Timesout, which is designed to time-out if you call yawn with a value greater than 99 (milliseconds). Note that the default time-out is 5 seconds, but life is too short to wait that long.

  @timeout 100
  def yawn(sleep) do
    GenServer.call(@name, {:yawn, sleep}, @timeout)
  end

  def handle_call({:yawn, sleep}, _from, call_count) do
    :timer.sleep(sleep)
    {:reply, {:previous_call_count, call_count}, call_count + 1}
  end

So ..

$ iex -S mix
iex(1)> Timesout.yawn(110)
** (exit) exited in: GenServer.call(, {:yawn, 110}, 100)
    ** (EXIT) time out
    (elixir) lib/gen_server.ex:774: GenServer.call/3

Time-out kills the client

When investigating this from iex it can be a bit confusing, as iex prevents errors and time-outs from killing the shell. (The Erlang REPL behaves differently - uncaught errors and time-outs kill the shell process.)

iex(1)> self
#PID<0.181.0>
iex(2)> exit(:whatevs)
** (exit) :whatevs

iex(2)> self
#PID<0.181.0>

The following illustrates the time-out killing the client.

iex(1)> self
#PID<0.181.0>
iex(2)> spawn_link(fn -> Timesout.yawn(110) end)
#PID<0.184.0>
** (EXIT from #PID<0.181.0>) evaluator process exited with reason: exited in: GenServer.call(, {:yawn, 110}, 100)
    ** (EXIT) time out

Interactive Elixir (1.5.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> self
#PID<0.186.0>

Time-out does not kill the server, or prevent the call from completing

The Timesout server keeps a count of all the number of calls that it has processed.

iex(1)> :sys.get_state(Timesout)
0
iex(2)> Timesout.yawn(110)
** (exit) exited in: GenServer.call(, {:yawn, 110}, 100)
    ** (EXIT) time out
    (elixir) lib/gen_server.ex:774: GenServer.call/3
iex(2)> :sys.get_state(Timesout)
1

Although the call timed-out, the operation still completed. This is important if your call has effects, is not idempotent, and you would consider retrying. It also means that a blocked call will continue to block the GenServer even after a time-out.

iex(1)> Timesout.yawn(60_000)
** (exit) exited in: GenServer.call(, {:yawn, 60000}, 100)
    ** (EXIT) time out
    (elixir) lib/gen_server.ex:774: GenServer.call/3
iex(1)> Timesout.yawn(1)
** (exit) exited in: GenServer.call(, {:yawn, 1}, 100)
    ** (EXIT) time out
    (elixir) lib/gen_server.ex:774: GenServer.call/3

Replying early

A GenServer call can reply before the end of the handle_call/3 function. In our example Timesout we also have

  def before_you_sleep(sleep) do
    GenServer.call(@name, {:before_you_sleep, sleep}, @timeout)
  end

  def handle_call({:before_you_sleep, sleep}, from, call_count) do
    GenServer.reply(from, {:previous_call_count, call_count})
    :timer.sleep(sleep)
    {:noreply, call_count + 1}
  end

Blocking after the reply will not provoke a time-out in that call.

iex(1)> Timesout.before_you_sleep(10_000)
{:previous_call_count, 0}

Catching the exit

The client can explicitly catch the exit, and stay alive.

iex(1)> self
#PID<0.181.0>
iex(2)> spawn_link(fn ->
...(2)>   try do
...(2)>     Timesout.yawn(110)
...(2)>   catch
...(2)>     :exit, value ->
...(2)>       IO.inspect {:caught_an_exit, value}
...(2)>   end
...(2)> end)
#PID<0.191.0>
{:caught_an_exit, {:timeout, {GenServer, :call, [, {:yawn, 110}, 100]}}}

iex(3)> self
#PID<0.181.0>

However remember that the server will also not die: the reply message will be sent to the client and if unhandled will clutter the mailbox. This also occurs when iex prevents exits:

iex(1)> Timesout.yawn(110)
** (exit) exited in: GenServer.call(, {:yawn, 110}, 100)
    ** (EXIT) time out
    (elixir) lib/gen_server.ex:774: GenServer.call/3
iex(1)> flush
{#Reference<0.2855926460.1725169668.87324>, {:previous_call_count, 0}}
:ok

Recap

When a GenServer times-out:-

  • The client process will exit.
  • The server will not exit and, if nothing else goes wrong, will complete the operation.
  • You can reply early from GenServer.handle_call/3 callback; that call will not time-out if the server blocks after the reply.
  • If you prevent the client dying by catching the exit then you should also be handling the response message, to prevent the client processes mailbox from filling up.

Acknowledgement

Thanks to Tetiana Dushenkivska for helping me out with the syntax for catching exits in Elixir.

Updates

  • 2018-02-27 fixed typo in a iex -S mix
We're passionate about understanding businesses, ideas and people. Let's Talk