Stop publishing a dead lane as available

c23e2eb7f970 · AtlantisPleb · · parent b65a63af5d22

Stop publishing a dead lane as available

The catalog answered one question — is a credential configured — and
published it as `availability`. That is a claim about wiring, and the
difference from a claim about whether the lane answers is not
academic: a nine-session fleet was blocked by a default model whose
credential was present and whose every call failed, while
`GET /api/v1/models` reported it available. A client that read the
catalog and picked that lane was misled by us, not by the provider.

`OpenAgents.Inference.Health` records what real calls did. The proxy
reports each success and each failure, with the upstream status the
failure carried, and the catalog derives a third word: `unavailable`
when there is no credential, `degraded` when the lane is configured
and its recent calls keep failing, `available` otherwise.

Three decisions worth stating. A lane nothing has called since boot is
available, not degraded — silence is not evidence of failure, and
treating it as failure would make every restart look like an outage. A
success clears the failure run outright rather than decrementing it,
because a lane that answers is working now and a grudge would keep
publishing degraded about a lane that recovered. And this is not a
circuit breaker: nothing here refuses a call or routes around a lane.
The only thing being fixed is the published claim.

State is per node and lives in ETS, so it is lost on restart. That is
correct — health is a statement about now, and a node that has just
booted has not tried anything, which is exactly unknown.

This is #238's second half and the fourth bullet of #199's contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 357 · 2026-08-25T10:11:07.451515Z

Changed files

  • modified lib/openagents/application.ex
  • added lib/openagents/inference/health.ex
  • modified lib/openagents/inference/models.ex
  • modified lib/openagents_web/controllers/inference_proxy_controller.ex
  • added test/openagents/inference/health_test.exs

Diff

5 files changed, +227 -1

lib/openagents/application.ex modified +1

@@ -53,6 +53,7 @@ defmodule OpenAgents.Application do

53 53
        OpenAgents.Forge.BootConverge,
54 54
        {DNSCluster, query: Application.get_env(:openagents, :dns_cluster_query) || :ignore},
55 55
        {Phoenix.PubSub, name: OpenAgents.PubSub},
56
        OpenAgents.Inference.Health,
56 57
        OpenAgents.RuntimeSupervisor,
57 58
        OpenAgentsWeb.BoxRateLimiter,
58 59
        {Registry, keys: :unique, name: OpenAgents.BoxRunRegistry},
lib/openagents/inference/health.ex added +115

@@ -0,0 +1,115 @@

1
defmodule OpenAgents.Inference.Health do
2
  @moduledoc """
3
  What each model lane has actually done lately, so the catalog can stop
4
  claiming a lane is available while every call to it fails.
5
6
  The catalog used to answer one question — is a credential configured — and
7
  publish the answer as `availability`. That is a claim about wiring, not about
8
  whether the lane answers, and the difference is not academic: a whole
9
  delegation fleet was blocked by a default model whose credential was present
10
  and whose every call failed, while `GET /api/v1/models` reported it
11
  `available` (#238, #199).
12
13
  This records the outcome of real calls and lets the catalog say what it
14
  knows:
15
16
  - `:unknown` — nothing has been tried since boot. Not a promise either way.
17
  - `:healthy` — the last call succeeded.
18
  - `:degraded` — `@degraded_after` consecutive failures with no success since.
19
20
  Deliberately not a circuit breaker. Nothing here refuses a call or routes
21
  around a lane; a caller that wants the failing lane still gets it. The only
22
  claim being fixed is the published one, because a client that reads the
23
  catalog and picks a dead lane was misled by us rather than by the provider.
24
25
  State is per node and lives in ETS. It is lost on restart, which is correct:
26
  health is a statement about now, and a node that has just booted has not
27
  tried anything yet — that is exactly `:unknown`.
28
  """
29
30
  use GenServer
31
32
  @table :openagents_inference_health
33
  @degraded_after 3
34
35
  @type status :: :unknown | :healthy | :degraded
36
37
  @doc "How many consecutive failures make a lane degraded."
38
  @spec degraded_after() :: pos_integer()
39
  def degraded_after, do: @degraded_after
40
41
  def start_link(options) do
42
    GenServer.start_link(__MODULE__, options, name: __MODULE__)
43
  end
44
45
  @impl true
46
  def init(_options) do
47
    :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
48
    {:ok, %{}}
49
  end
50
51
  @doc """
52
  Record that a call to `model_id` answered.
53
54
  Success clears the failure run outright rather than decrementing it: a lane
55
  that answers is working now, and holding a grudge for earlier failures would
56
  keep publishing `degraded` about a lane that recovered.
57
  """
58
  @spec record_success(String.t()) :: :ok
59
  def record_success(model_id) when is_binary(model_id) do
60
    if table?(), do: :ets.insert(@table, {model_id, 0, :healthy})
61
    :ok
62
  end
63
64
  @doc """
65
  Record that a call to `model_id` failed.
66
67
  `status` is the upstream HTTP status when the failure carried one
68
  (`OpenAgents.OperationalLog.status/1`) and `nil` otherwise. It is kept so an
69
  operator reading health sees *why*, and never inferred: a failure with no
70
  status reports none.
71
  """
72
  @spec record_failure(String.t(), pos_integer() | nil) :: :ok
73
  def record_failure(model_id, status \\ nil) when is_binary(model_id) do
74
    if table?() do
75
      failures =
76
        case :ets.lookup(@table, model_id) do
77
          [{^model_id, count, _}] when is_integer(count) -> count + 1
78
          _ -> 1
79
        end
80
81
      :ets.insert(@table, {model_id, failures, {:failed, status}})
82
    end
83
84
    :ok
85
  end
86
87
  @doc "The status of one lane, and the last upstream status when it failed."
88
  @spec status(String.t()) :: {status(), pos_integer() | nil}
89
  def status(model_id) when is_binary(model_id) do
90
    case table?() && :ets.lookup(@table, model_id) do
91
      [{^model_id, 0, :healthy}] ->
92
        {:healthy, nil}
93
94
      [{^model_id, failures, {:failed, upstream}}] when failures >= @degraded_after ->
95
        {:degraded, upstream}
96
97
      [{^model_id, _failures, {:failed, upstream}}] ->
98
        # Below the threshold a lane is not yet degraded, but the last failure
99
        # is still worth surfacing to whoever asks.
100
        {:healthy, upstream}
101
102
      _ ->
103
        {:unknown, nil}
104
    end
105
  end
106
107
  @doc "Forget everything recorded. For tests and for an operator resetting a lane."
108
  @spec reset() :: :ok
109
  def reset do
110
    if table?(), do: :ets.delete_all_objects(@table)
111
    :ok
112
  end
113
114
  defp table?, do: :ets.whereis(@table) != :undefined
115
end
lib/openagents/inference/models.ex modified +25 -1

@@ -1,4 +1,6 @@

1 1
defmodule OpenAgents.Inference.Models do
2
  alias OpenAgents.Inference.Health
3
2 4
  @moduledoc """
3 5
  The typed model catalog: every model this deployment serves, and the
4 6
  provider lane that serves each.

@@ -98,6 +100,28 @@ defmodule OpenAgents.Inference.Models do

98 100
  test adapters need no credential, and an adapter that cannot say is refused
99 101
  at call time by its own `missing_api_key` rather than guessed at here.
100 102
  """
103
  @doc """
104
  What a client should believe about a lane, as one word.
105
106
  `unavailable` means the deployment cannot call it at all — no credential.
107
  `degraded` means it is configured and its recent calls have failed, which is
108
  the case the old two-word answer could not express: a lane whose wiring is
109
  right and whose every call fails used to publish `available` and mislead the
110
  caller that trusted it (#238).
111
112
  A lane nothing has called since boot is `available`, not `degraded`. Silence
113
  is not evidence of failure, and refusing to offer an untried lane would make
114
  every restart look like an outage.
115
  """
116
  @spec availability(t()) :: String.t()
117
  def availability(%{id: id} = model) do
118
    cond do
119
      not available?(model) -> "unavailable"
120
      match?({:degraded, _}, Health.status(id)) -> "degraded"
121
      true -> "available"
122
    end
123
  end
124
101 125
  @spec available?(t()) :: boolean()
102 126
  def available?(%{adapter: adapter}) do
103 127
    if Code.ensure_loaded?(adapter) and function_exported?(adapter, :configured?, 0) do

@@ -124,7 +148,7 @@ defmodule OpenAgents.Inference.Models do

124 148
        "provider" => Atom.to_string(model.provider),
125 149
        "context_window" => model.context_window,
126 150
        "max_output" => model.max_output,
127
        "availability" => if(available?(model), do: "available", else: "unavailable"),
151
        "availability" => availability(model),
128 152
        "default" => model.id == default_id
129 153
      }
130 154
    end)
lib/openagents_web/controllers/inference_proxy_controller.ex modified +5

@@ -192,6 +192,8 @@ defmodule OpenAgentsWeb.InferenceProxyController do

192 192
        # answered, not what it assumed (PROVIDER-002). Because a mismatched
193 193
        # request was refused above, requested and effective are the same
194 194
        # name on every 200.
195
        OpenAgents.Inference.Health.record_success(model.id)
196
195 197
        conn
196 198
        |> put_resp_content_type("text/event-stream")
197 199
        |> put_resp_header("cache-control", "no-store")

@@ -205,6 +207,9 @@ defmodule OpenAgentsWeb.InferenceProxyController do

205 207
        if usage != %{}, do: meter(grant, usage)
206 208
        class = OpenAgents.OperationalLog.code(reason)
207 209
        status = OpenAgents.OperationalLog.status(reason)
210
        # What the catalog publishes about this lane follows from what it
211
        # actually did, not only from whether a credential is configured.
212
        OpenAgents.Inference.Health.record_failure(model.id, status)
208 213
        Logger.warning(
209 214
          "inference_proxy_failed code=#{class}" <>
210 215
            if(status == nil, do: "", else: " upstream_status=#{status}")
test/openagents/inference/health_test.exs added +81

@@ -0,0 +1,81 @@

1
defmodule OpenAgents.Inference.HealthTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.Inference.Health
5
  alias OpenAgents.Inference.Models
6
7
  setup do
8
    Health.reset()
9
    on_exit(&Health.reset/0)
10
    :ok
11
  end
12
13
  describe "what a lane has lately done" do
14
    test "a lane nothing has called is unknown, which is not a verdict either way" do
15
      assert Health.status("never-called") == {:unknown, nil}
16
    end
17
18
    test "a success makes a lane healthy" do
19
      Health.record_success("gpt-5.6-luna")
20
      assert Health.status("gpt-5.6-luna") == {:healthy, nil}
21
    end
22
23
    test "one failure is not yet degraded, but the upstream status is kept" do
24
      Health.record_failure("gemini-3.7-flash", 503)
25
      assert Health.status("gemini-3.7-flash") == {:healthy, 503}
26
    end
27
28
    test "consecutive failures degrade the lane and carry the last status" do
29
      for _ <- 1..Health.degraded_after(), do: Health.record_failure("gemini-3.7-flash", 429)
30
      assert Health.status("gemini-3.7-flash") == {:degraded, 429}
31
    end
32
33
    test "a success clears the failure run rather than decrementing it" do
34
      for _ <- 1..Health.degraded_after(), do: Health.record_failure("gemini-3.7-flash", 500)
35
      assert {:degraded, _} = Health.status("gemini-3.7-flash")
36
37
      Health.record_success("gemini-3.7-flash")
38
      assert Health.status("gemini-3.7-flash") == {:healthy, nil}
39
    end
40
41
    test "a failure with no upstream status reports none rather than inventing one" do
42
      for _ <- 1..Health.degraded_after(), do: Health.record_failure("ox-alpha", nil)
43
      assert Health.status("ox-alpha") == {:degraded, nil}
44
    end
45
46
    test "lanes are tracked apart" do
47
      for _ <- 1..Health.degraded_after(), do: Health.record_failure("gemini-3.7-flash", 502)
48
      Health.record_success("gpt-5.6-luna")
49
50
      assert {:degraded, 502} = Health.status("gemini-3.7-flash")
51
      assert {:healthy, nil} = Health.status("gpt-5.6-luna")
52
    end
53
  end
54
55
  describe "what the catalog publishes" do
56
    test "an untried lane still reads available, because silence is not failure" do
57
      entry = Enum.find(Models.catalog(), &(&1["id"] == "gemini-3.7-flash"))
58
      assert entry["availability"] == "available"
59
    end
60
61
    test "a lane whose calls keep failing stops claiming it is available" do
62
      for _ <- 1..Health.degraded_after(), do: Health.record_failure("gemini-3.7-flash", 503)
63
64
      entry = Enum.find(Models.catalog(), &(&1["id"] == "gemini-3.7-flash"))
65
      assert entry["availability"] == "degraded"
66
67
      # The lanes that are answering are unaffected, so a client can still pick
68
      # one that works — the whole point of publishing this.
69
      others = Enum.reject(Models.catalog(), &(&1["id"] == "gemini-3.7-flash"))
70
      assert Enum.all?(others, &(&1["availability"] in ["available", "unavailable"]))
71
    end
72
73
    test "a recovered lane goes back to available" do
74
      for _ <- 1..Health.degraded_after(), do: Health.record_failure("gemini-3.7-flash", 503)
75
      Health.record_success("gemini-3.7-flash")
76
77
      entry = Enum.find(Models.catalog(), &(&1["id"] == "gemini-3.7-flash"))
78
      assert entry["availability"] == "available"
79
    end
80
  end
81
end

This page updates live while a promote is in flight · changelog