defmodule ApiBrasil.Core.Retry do @moduledoc """ Política de retry da SDK. Por padrão a SDK tenta novamente apenas em **HTTP 429** (rate limit) e em **falhas de conexão** — nunca em timeouts nem em erros de negócio, para não duplicar cobranças/envios. ApiBrasil.new( retry: %ApiBrasil.Core.Retry{ retries: 3, min_delay: 500, retry_on_statuses: [429, 503] } ) # ou desativando o retry ApiBrasil.new(retry: ApiBrasil.Core.Retry.none()) Todos os tempos são em milissegundos. """ @type t :: %__MODULE__{ retries: non_neg_integer(), min_delay: non_neg_integer(), max_delay: non_neg_integer(), retry_on_statuses: [pos_integer()] } @doc """ Política de retry. - `:retries` — novas tentativas além da original. Padrão: `2`. - `:min_delay` — atraso base do backoff exponencial, em ms. Padrão: `300`. - `:max_delay` — teto do atraso entre tentativas, em ms. Padrão: `5_000`. - `:retry_on_statuses` — status HTTP que disparam retry. Padrão: `[429]`. """ defstruct retries: 2, min_delay: 300, max_delay: 5_000, retry_on_statuses: [429] @doc "Cria a política a partir de uma keyword list ou de um mapa." @spec new(t() | keyword() | map()) :: t() def new(%__MODULE__{} = retry), do: retry def new(fields) when is_list(fields) or is_map(fields), do: struct!(__MODULE__, fields) @doc "Política que desativa o retry." @spec none() :: t() def none, do: %__MODULE__{retries: 0} @doc "Número total de tentativas (a original mais os retries)." @spec max_attempts(t()) :: pos_integer() def max_attempts(%__MODULE__{retries: retries}), do: retries + 1 @doc "Informa se um status HTTP dispara retry nesta política." @spec retries_status?(t(), pos_integer()) :: boolean() def retries_status?(%__MODULE__{retry_on_statuses: statuses}, status), do: status in statuses @doc """ Calcula o backoff exponencial com jitter: `min_delay * 2^tentativa`, limitado a `max_delay`. A tentativa é contada a partir de `0`. """ @spec backoff_delay(t(), non_neg_integer()) :: non_neg_integer() def backoff_delay(%__MODULE__{} = retry, attempt) when attempt >= 0 do exponential = retry.min_delay * :math.pow(2, attempt) jittered = exponential * (0.5 + :rand.uniform() * 0.5) jittered |> round() |> min(retry.max_delay) |> max(0) end @doc "Aguarda o atraso informado. Zero não bloqueia." @spec sleep(non_neg_integer()) :: :ok def sleep(0), do: :ok def sleep(delay) when is_integer(delay) and delay > 0, do: Process.sleep(delay) end