defmodule TuskerPush.Apns do require OpenTelemetry.Tracer require Logger @type environment() :: :development | :production @spec send(environment(), String.t(), Map.t()) :: :ok | {:error, term()} def send(apns_env, apns_device_token, payload) do :telemetry.execute([:apns, :push], %{monotonic_time: System.monotonic_time()}) OpenTelemetry.Tracer.with_span :apns_push, %{attributes: [env: apns_env]} do do_send(apns_env, apns_device_token, payload) end end @spec do_send(environment(), String.t(), Map.t()) :: :ok | {:error, term()} defp do_send(apns_env, apns_device_token, payload) do with {:ok, body} <- Jason.encode(payload, pretty_print: false), req <- make_request(apns_env, apns_device_token, body), {:ok, resp} <- Finch.request(req, TuskerPush.Finch) do handle_response(resp, apns_env, apns_device_token, payload) else {:error, %Finch.Error{reason: :connection_closed}} -> Logger.warning("Apns Finch connection_closed, retrying in 1s") OpenTelemetry.Tracer.add_event("Finch connection_closed", []) Process.sleep(1000) __MODULE__.send(apns_env, apns_device_token, payload) {:error, %Finch.Error{reason: :disconnected}} -> Logger.warning("Apns Finch disconnected, retrying in 1s") OpenTelemetry.Tracer.add_event("Finch disconnected", []) Process.sleep(1000) __MODULE__.send(apns_env, apns_device_token, payload) {:error, %Mint.TransportError{reason: :closed}} -> Logger.warning("Apns Mint transport closed, retrying in 1s") OpenTelemetry.Tracer.add_event("Mint transport closed", []) Process.sleep(1000) __MODULE__.send(apns_env, apns_device_token, payload) {:error, reason} -> {:error, reason} end end @spec make_request(environment(), String.t(), binary()) :: Finch.Request.t() defp make_request(apns_env, apns_device_token, body) do bundle_id = Application.fetch_env!(:tusker_push, :apns)[:bundle_id] headers = [ {"authorization", "bearer #{TuskerPush.Apns.Token.current()}"}, {"apns-push-type", "alert"}, {"apns-bundle-id", bundle_id}, {"apns-topic", bundle_id}, {"apns-expiration", DateTime.utc_now() |> DateTime.add(1, :day) |> DateTime.to_unix() |> Integer.to_string()} ] Finch.build( :post, "https://#{host(apns_env)}/3/device/#{apns_device_token}", headers, body ) end @spec handle_response(Finch.Response.t(), environment(), String.t(), Map.t()) :: :ok | {:error, term()} defp handle_response(resp, apns_env, apns_device_token, payload) do maybe_log_unique_id(resp, apns_env) if resp.status in 200..299 do :ok else info = case Jason.decode(resp.body) do {:ok, data} -> inspect(data) {:error, _} -> resp.body end case {resp.status, info} do {403, %{reason: "ExpiredProviderToken"}} -> Logger.warning("Expired provider token, retrying") OpenTelemetry.Tracer.add_event("Expired provider token", []) __MODULE__.send(apns_env, apns_device_token, payload) {410, %{reason: "Unregistered"}} -> Logger.warning("Device token unregistered") OpenTelemetry.Tracer.set_status(:error, "Unregistered device token") {:error, :device_token_unregistered} _ -> Logger.error("Received #{resp.status} with #{inspect(info)}") OpenTelemetry.Tracer.set_attributes( response_status: resp.status, response_info: inspect(info) ) OpenTelemetry.Tracer.set_status(:error, "Unexpected response") {:error, "unexpected status #{resp.status}"} end end end @spec maybe_log_unique_id(Finch.Response.t(), :development | :production) :: :ok defp maybe_log_unique_id(resp, :development) do resp.headers |> Enum.find(fn {"apns-unique-id", _} -> true _ -> false end) |> case do {_, id} -> Logger.debug("APNS unique id: #{id}") OpenTelemetry.Tracer.add_event("APNS unique ID", unique_id: id) _ -> nil end :ok end defp maybe_log_unique_id(_resp, :production), do: :ok @spec host(environment()) :: String.t() def host(:development) do "api.sandbox.push.apple.com" end def host(:production) do "api.push.apple.com" end end