defmodule Tesla.Middleware.Timeout do
  @moduledoc """
  Timeout HTTP request after X milliseconds.

  ## Examples

  ```elixir
  defmodule MyClient do
    def client do
      Tesla.client([
        {Tesla.Middleware.Timeout, timeout: 2_000}
      ])
    end
  end
  ```

  If you are using OpenTelemetry in your project, you may be interested in
  using `OpentelemetryProcessPropagator.Task` to have a better integration using
  the `task_module` option.

  ```elixir
  defmodule MyClient do
    def client do
      Tesla.client([
        {Tesla.Middleware.Timeout, [
          timeout: 2_000,
          task_module: OpentelemetryProcessPropagator.Task
        }
      ])
    end
  end
  ```

  ## Options

  - `:timeout` - number of milliseconds a request is allowed to take (defaults to `1000`)
  - `:task_module` - the `Task` module used to spawn tasks. Useful when you want
    to use alternatives such as `OpentelemetryProcessPropagator.Task` from the OTEL
    project.
  """

  @behaviour Tesla.Middleware

  @default_timeout 1_000

  @impl Tesla.Middleware
  def call(env, next, opts) do
    opts = opts || []
    timeout = Keyword.get(opts, :timeout, @default_timeout)
    task_module = Keyword.get(opts, :task_module, Task)

    task = safe_async(task_module, fn -> Tesla.run(env, next) end)

    try do
      task
      |> task_module.await(timeout)
      |> repass_error
    catch
      :exit, {:timeout, _} ->
        task_module.shutdown(task, 0)
        {:error, :timeout}
    end
  end

  defp safe_async(task_module, func) do
    task_module.async(fn ->
      try do
        {:ok, func.()}
      rescue
        e in _ ->
          {:exception, e, __STACKTRACE__}
      catch
        type, value ->
          {type, value}
      end
    end)
  end

  defp repass_error({:exception, error, stacktrace}), do: reraise(error, stacktrace)

  defp repass_error({:throw, value}), do: throw(value)

  defp repass_error({:exit, value}), do: exit(value)

  defp repass_error({:ok, result}), do: result
end
