defmodule CMDC.Agent.Retry do @moduledoc """ Provider 错误分类与重试延迟计算。 ## 错误分类 - **Transient(暂时性)** — 限流、服务器过载、网络断开等,等待后重试可能成功。 - **Permanent(永久性)** — 认证失败、上下文溢出、无效请求,重试无意义。 永久性错误优先检测——若同一错误同时包含永久与暂时模式,归为永久性。 ## 退避策略 指数退避:`delay = base_ms × 2^(attempt-1)`,上限 `max_ms`。 不引入 jitter——单 Agent 并发请求量不足以受益,且确定性延迟便于测试。 """ alias CMDC.Agent.Overflow @transient_patterns [ "overloaded", "rate_limit", "rate limit", "too many requests", "429", "500", "502", "503", "504", "connection", "econnreset", "econnrefused", "etimedout", "fetch failed", "socket hang up", "request timeout", "server_error", "timeout", "recv_timeout", "connect_timeout", "read_timeout", "stream_timeout", "closed", "transport_error" ] @permanent_patterns Overflow.overflow_patterns() ++ [ "unauthorized", "invalid_api_key", "authentication" ] # ========================================================================== # Public API # ========================================================================== @doc """ 返回 `true` 如果该错误是暂时性的、可以重试。 ## 示例 iex> CMDC.Agent.Retry.retryable?("429 Too Many Requests") true iex> CMDC.Agent.Retry.retryable?("context_length_exceeded") false """ @spec retryable?(term()) :: boolean() def retryable?(reason) do text = stringify(reason) |> String.downcase() not matches_any?(text, @permanent_patterns) and matches_any?(text, @transient_patterns) end @doc """ 计算第 `attempt` 次重试的延迟毫秒数。 ## 选项 - `:base_ms` — 初始延迟(默认 `2_000`) - `:max_ms` — 最大延迟(默认 `60_000`) ## 示例 iex> CMDC.Agent.Retry.delay(1) 2_000 iex> CMDC.Agent.Retry.delay(3) 8_000 """ @spec delay(pos_integer(), keyword()) :: pos_integer() def delay(attempt, opts \\ []) do base = Keyword.get(opts, :base_ms, 2_000) max = Keyword.get(opts, :max_ms, 60_000) min(base * Integer.pow(2, attempt - 1), max) end @doc "返回暂时性错误模式列表。" @spec transient_patterns() :: [String.t()] def transient_patterns, do: @transient_patterns @doc "返回永久性错误模式列表。" @spec permanent_patterns() :: [String.t()] def permanent_patterns, do: @permanent_patterns # ========================================================================== # 私有辅助 # ========================================================================== defp stringify(reason) when is_binary(reason), do: reason defp stringify(reason) when is_atom(reason), do: Atom.to_string(reason) defp stringify(reason), do: inspect(reason) defp matches_any?(text, patterns) do Enum.any?(patterns, &String.contains?(text, &1)) end end