From 6ae76b952cf08cc641c5640ba87a5e6d51b308c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lorenzo=20S=C3=A1nchez=20Fraile?= Date: Mon, 6 Jul 2026 19:11:02 +0200 Subject: [PATCH 1/3] refactor(candil): remove unused Candil.Retry + align source_ref to v0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3, Fixes #4 — CAND-001 and CAND-002 from Phase 0 audit. CAND-002 (Candil.Retry removal): Candil.Retry duplicated Apero.Retry with near-identical API but had zero callers in production code (grep confirms only moduledoc examples and self-references). The actual retry path is Candil.HTTP → Apero.Retry (see candil/lib/candil/http.ex:12,57). Removing 192 lines of dead code that was likely a re-implementation during the ap ero → ap ero/candil/botica split that nobody caught. CAND-001 (source_ref alignment): mix.exs declared v0.3.0 and CHANGELOG marked [0.3.0] - 2026-06-27, but source_ref pointed to v0.1.0 (a tag that doesn't even exist — only v1.0.0 is tagged). Aligning to v0.3.0. Note: tag v0.3.0 will be created on main after this PR merges (per the standard hex.pm publishing workflow). --- lib/candil/retry.ex | 192 -------------------------------------------- mix.exs | 5 +- 2 files changed, 2 insertions(+), 195 deletions(-) delete mode 100644 lib/candil/retry.ex diff --git a/lib/candil/retry.ex b/lib/candil/retry.ex deleted file mode 100644 index 0529c6a..0000000 --- a/lib/candil/retry.ex +++ /dev/null @@ -1,192 +0,0 @@ -defmodule Candil.Retry do - @moduledoc """ - Retry with exponential backoff for remote operations. - - Provides utilities for retrying operations that may fail transiently, - such as network requests to LLM providers. - - ## Configuration - - Default values can be overridden via application config: - - config :candil, Candil.Retry, - max_retries: 3, - base_delay: 1000, - max_delay: 30_000, - jitter: 0.1 - - """ - - @default_max_retries 3 - @default_base_delay 1000 - @default_max_delay 30_000 - @default_jitter 0.1 - - @type retryable_error :: {:error, :timeout | :rate_limited | :http_error} - - @doc """ - Executes a function with exponential backoff retry. - - ## Options - - * `:max_retries` — maximum number of retry attempts (default: 3) - * `:base_delay` — base delay in milliseconds (default: 1000) - * `:max_delay` — maximum delay in milliseconds (default: 30_000) - * `:jitter` — random jitter factor 0.0-1.0 (default: 0.1) - * `:retry_on` — list of error reasons to retry on (default: `[:timeout, :rate_limited]`) - - ## Examples - - Candil.Retry.with_retry(fn -> - Req.post(url, json: body) - end) - - Candil.Retry.with_retry(fn -> - some_operation() - end, max_retries: 5, base_delay: 500) - - """ - @spec with_retry((-> {:ok, term()} | {:error, term()}), keyword()) :: - {:ok, term()} | {:error, term()} - def with_retry(fun, opts \\ []) when is_function(fun, 0) do - max_retries = Keyword.get(opts, :max_retries, default_max_retries()) - base_delay = Keyword.get(opts, :base_delay, default_base_delay()) - max_delay = Keyword.get(opts, :max_delay, default_max_delay()) - jitter_factor = Keyword.get(opts, :jitter, default_jitter()) - retry_on = Keyword.get(opts, :retry_on, default_retry_on()) - - do_retry(fun, max_retries, base_delay, max_delay, jitter_factor, retry_on, 0) - end - - @doc """ - Non-blocking variant of `with_retry/2` that uses `Process.send_after` - between attempts instead of `:timer.sleep`. - - Returns a tuple `{delay_ms, message}` where `message` is - `{:candil_retry, fun, max_retries, base_delay, max_delay, jitter, - retry_on, attempt + 1}` and `delay_ms` is the time the caller should - pass to `Process.send_after(self(), message, delay_ms)`. The caller - (typically a GenServer) handles the message via `handle_retry_message/1`. - - This is the safe way to retry from inside a GenServer mailbox loop. - The original `with_retry/2` still uses `:timer.sleep` and is - appropriate for scripts / one-off code paths. - """ - @spec schedule_retry( - (-> {:ok, term()} | {:error, term()}), - non_neg_integer(), - non_neg_integer(), - non_neg_integer(), - float(), - [atom()], - non_neg_integer() - ) :: {pos_integer(), tuple()} - def schedule_retry(fun, max_retries, base_delay, max_delay, jitter, retry_on, attempt) do - message = - {:candil_retry, fun, max_retries, base_delay, max_delay, jitter, retry_on, attempt + 1} - - delay = calculate_delay(attempt, base_delay, max_delay, jitter) - {delay, message} - end - - @doc """ - Handles a `{:candil_retry, ...}` message produced by - `schedule_retry/7`. Runs the next attempt; on success returns - `{:candil_retry_done, result}`, on retryable failure returns - `{:candil_retry_pending, delay, message}` (the caller sends itself - the message after `delay` ms), and on terminal failure returns - `{:candil_retry_done, {:error, reason}}`. - """ - @spec handle_retry_message(tuple()) :: - {:candil_retry_done, term()} | {:candil_retry_pending, pos_integer(), tuple()} - def handle_retry_message( - {:candil_retry, fun, max_retries, base_delay, max_delay, jitter, retry_on, attempt} - ) do - case fun.() do - {:ok, _} = result -> - {:candil_retry_done, result} - - {:error, reason} = error -> - if attempt < max_retries and retryable?(reason, retry_on) do - schedule_retry(fun, max_retries, base_delay, max_delay, jitter, retry_on, attempt) - |> case do - {delay, message} -> {:candil_retry_pending, delay, message} - end - else - {:candil_retry_done, error} - end - end - end - - defp do_retry(fun, max_retries, base_delay, max_delay, jitter, retry_on, attempt) do - case fun.() do - {:ok, _} = result -> - result - - {:error, reason} = error -> - if attempt < max_retries and retryable?(reason, retry_on) do - delay = calculate_delay(attempt, base_delay, max_delay, jitter) - - :timer.sleep(delay) - - do_retry( - fun, - max_retries, - base_delay, - max_delay, - jitter, - retry_on, - attempt + 1 - ) - else - error - end - end - end - - defp retryable?(reason, retry_on) when is_list(retry_on) do - reason in retry_on - end - - defp retryable?(_reason, _retry_on), do: false - - defp calculate_delay(attempt, base_delay, max_delay, jitter) do - exponential = :math.pow(2, attempt) * base_delay - capped = min(exponential, max_delay) - - jitter_amount = capped * jitter * (:rand.uniform() * 2 - 1) - floor(capped + jitter_amount) - end - - # Default getters from application config - defp default_max_retries, - do: - Application.get_env(:candil, __MODULE__, []) - |> Keyword.get(:max_retries, @default_max_retries) - - defp default_base_delay, - do: - Application.get_env(:candil, __MODULE__, []) - |> Keyword.get(:base_delay, @default_base_delay) - - defp default_max_delay, - do: - Application.get_env(:candil, __MODULE__, []) |> Keyword.get(:max_delay, @default_max_delay) - - defp default_jitter, - do: Application.get_env(:candil, __MODULE__, []) |> Keyword.get(:jitter, @default_jitter) - - defp default_retry_on, do: [:timeout, :rate_limited] - - @doc """ - Returns the delay for a given attempt number (useful for testing). - """ - @spec delay_for_attempt(non_neg_integer(), keyword()) :: non_neg_integer() - def delay_for_attempt(attempt, opts \\ []) do - base_delay = Keyword.get(opts, :base_delay, @default_base_delay) - max_delay = Keyword.get(opts, :max_delay, @default_max_delay) - jitter = Keyword.get(opts, :jitter, @default_jitter) - - calculate_delay(attempt, base_delay, max_delay, jitter) - end -end diff --git a/mix.exs b/mix.exs index 1ce910d..85e8574 100644 --- a/mix.exs +++ b/mix.exs @@ -50,7 +50,7 @@ defmodule Candil.MixProject do main: "readme", source_url: "https://github.com/Lorenzo-SF/candil", homepage_url: "https://github.com/Lorenzo-SF/candil", - source_ref: "v0.1.0", + source_ref: "v0.3.0", extras: ["README.md", "LICENSE.md"], groups_for_modules: [ Core: [Candil, Candil.Llm, Candil.Error, Candil.Cost], @@ -58,8 +58,7 @@ defmodule Candil.MixProject do Diagnostics: [Candil.Health, Candil.Embeddings], Conversation: [Candil.Conversation], Inference: [Candil.Inference, Candil.RequestBuilder, Candil.Stream, Candil.HTTP], - Engine: [Candil.Engine, Candil.Engine.Server, Candil.Detector, Candil.Installer], - Retry: [Candil.Retry] + Engine: [Candil.Engine, Candil.Engine.Server, Candil.Detector, Candil.Installer] ] ] end From 78702df1b5738c4210a8fcd83c3b1e73086dbabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lorenzo=20S=C3=A1nchez=20Fraile?= Date: Mon, 6 Jul 2026 19:11:15 +0200 Subject: [PATCH 2/3] style(candil): mix format + CHANGELOG consistency cleanup Pre-Phase-0 WIP from local working tree (preserved via stash). - CHANGELOG: remove duplicate '## [0.2.0] - 2026-06-24' heading (artifact of a prior edit), add the 'A note on history' footer matching the convention used in ap ero and botica - config_manager.ex, health.ex: apply mix format Orthogonal to the Phase 0 cleanup but bundled in this PR to avoid leaving the working tree dirty. --- CHANGELOG.md | 19 +++++++++++++++++-- lib/candil/config_manager.ex | 8 ++++++-- lib/candil/health.ex | 7 +++++-- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 141cf55..6b7e3fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.2.0] - 2026-06-24 -## [0.2.0] - 2026-06-24 - ### Added - `Candil.Cost` — cost estimation for LLM API usage with pricing table for OpenAI, Anthropic, and local models. - `Candil.Application` — OTP application with ETS-based config and DynamicSupervisor for engine management. @@ -63,3 +61,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [1.0.0]: https://hex.pm/packages/candil/1.0.0 [0.2.0]: https://github.com/Lorenzo-SF/candil/releases/tag/v0.2.0 + + +> ## A note on history +> +> The git history of this repository was rewritten as part of a +> deliberate cleanup effort. The commits you can read describe the +> codebase as it stands today — they do not preserve the original +> chronology of development. +> +> Anything worth keeping from before the rewrite was carried forward +> as tagged releases with explicit `CHANGELOG.md` entries. Anything +> not preserved is, by the maintainer's choice, no longer part of the +> canonical development line. +> +> Tag `v1.0.0` points to the initial open-source cut-over. +> All versioned artifacts on Hex.pm and GitHub Releases follow this +> convention. diff --git a/lib/candil/config_manager.ex b/lib/candil/config_manager.ex index cf721a6..f8e2905 100644 --- a/lib/candil/config_manager.ex +++ b/lib/candil/config_manager.ex @@ -104,14 +104,18 @@ defmodule Candil.ConfigManager do defp check_url_format(errors, config) do case Map.get(config, "url") do - nil -> errors + nil -> + errors + url when is_binary(url) -> if String.starts_with?(url, "http://") or String.starts_with?(url, "https://") do errors else [errors | "url must start with http:// or https://"] end - _ -> errors + + _ -> + errors end end diff --git a/lib/candil/health.ex b/lib/candil/health.ex index b86e351..7da6fa0 100644 --- a/lib/candil/health.ex +++ b/lib/candil/health.ex @@ -95,8 +95,11 @@ defmodule Candil.Health do end defp http_post(url, body, timeout) do - case Req.post(url, body: body, headers: [{"content-type", "application/json"}], - receive_timeout: timeout) do + case Req.post(url, + body: body, + headers: [{"content-type", "application/json"}], + receive_timeout: timeout + ) do {:ok, %{status: s, body: body}} -> {:ok, s, body} {:error, reason} -> {:error, inspect(reason)} end From 07687babb8cbcdd03bd90ed0131d6bed0bfaf2c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lorenzo=20S=C3=A1nchez=20Fraile?= Date: Sun, 5 Jul 2026 23:41:46 +0200 Subject: [PATCH 3/3] docs(candil): add 'A note on versioning' footer + drop dangling v0.2.0 link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes explicit that the only canonical tag is v1.0.0 (the initial open-source cut-over) and that the [0.X.Y] headers in the CHANGELOG are planning milestones, not releases. Also removes the dangling [0.2.0]: release-link reference at the bottom of the file — that tag was never created on remote, and mix.exs source_ref now points to v0.3.0 instead, so the link would 404 anyway. Mirrors the same convention the user maintains across the public projects (pote, alaja, etc.). Sibling change: same footer on botica/CHANGELOG.md. --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7e3fc..8e72917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,8 +60,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [1.0.0]: https://hex.pm/packages/candil/1.0.0 -[0.2.0]: https://github.com/Lorenzo-SF/candil/releases/tag/v0.2.0 +> ## A note on versioning +> +> The only canonical tag is `v1.0.0` — the initial open-source +> cut-over. Any `[0.X.Y]` headers above are **planning milestones**, +> not releases: they have no corresponding git tag. `mix.exs` +> `version` reflects the current development state and may be ahead +> of the public surface. > ## A note on history >