Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: don't re-register garden on transient auth failures

Garden socket was clearing credentials and re-registering whenever
reauthentication failed, including for connection/transport errors.
Now only re-register when the server actually rejects the credentials
(4xx response); bubble up transport and 5xx errors so the socket
reconnect backoff handles the retry with existing credentials.

sow-173

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+130 -8
+18 -5
apps/garden/lib/garden/auth.ex
··· 51 51 token 52 52 end 53 53 54 - def request_token(client_id, private_key_pem) do 54 + def request_token(client_id, private_key_pem, post_fun \\ &Req.post/2) do 55 55 endpoint = Garden.Config.get().endpoint 56 56 assertion = build_assertion(client_id, private_key_pem) 57 57 58 - case Req.post("#{endpoint}/api/oauth/token", 58 + case post_fun.("#{endpoint}/api/oauth/token", 59 59 json: %{ 60 60 grant_type: "client_credentials", 61 61 client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", ··· 71 71 token_type: body["token_type"] 72 72 }} 73 73 74 + {:ok, %{status: status, body: body}} when status >= 400 and status < 500 -> 75 + Logger.warning( 76 + msg: "Token request rejected by server", 77 + status: to_string(status), 78 + error: inspect(body) 79 + ) 80 + 81 + {:error, {:server_rejected, status}} 82 + 74 83 {:ok, %{status: status, body: body}} -> 75 84 Logger.warning( 76 - msg: "Token request failed", 85 + msg: "Token request server error", 77 86 status: to_string(status), 78 87 error: inspect(body) 79 88 ) 80 89 81 - {:error, :token_request_failed} 90 + {:error, {:server_error, status}} 91 + 92 + {:error, %Req.TransportError{reason: reason}} -> 93 + Logger.warning(msg: "Token request transport error", reason: inspect(reason)) 94 + {:error, {:transport_error, reason}} 82 95 83 96 {:error, error} -> 84 97 Logger.warning(msg: "Token request error", error: inspect(error)) 85 - {:error, :token_request_failed} 98 + {:error, {:request_error, error}} 86 99 end 87 100 end 88 101 end
+12 -3
apps/garden/lib/garden/socket.ex
··· 257 257 {:ok, _} = ok -> 258 258 ok 259 259 260 - {:error, reason} -> 260 + {:error, {:reauthentication_failed, {:server_rejected, status}}} -> 261 261 Logger.warning( 262 - msg: "Reauthentication failed, clearing credentials and re-registering", 262 + msg: "Server rejected credentials, clearing and re-registering", 263 263 garden_sid: storage.garden_sid, 264 - reason: inspect(reason) 264 + status: to_string(status) 265 265 ) 266 266 267 267 storage = ··· 270 270 |> Map.delete(:oauth_credentials) 271 271 272 272 try_http_registration(storage) 273 + 274 + {:error, reason} = err -> 275 + Logger.warning( 276 + msg: "Reauthentication failed", 277 + garden_sid: storage.garden_sid, 278 + reason: inspect(reason) 279 + ) 280 + 281 + err 273 282 end 274 283 end 275 284
+100
apps/garden/test/garden/auth_test.exs
··· 1 + defmodule Garden.AuthTest do 2 + use ExUnit.Case, async: true 3 + 4 + import ExUnit.CaptureLog 5 + 6 + alias Garden.Auth 7 + 8 + @client_id "grdn_client_abc" 9 + 10 + setup do 11 + original = Application.get_env(:garden, :config) 12 + Application.put_env(:garden, :config, %SowerClient.Config{endpoint: "https://sower.test"}) 13 + 14 + on_exit(fn -> 15 + if original do 16 + Application.put_env(:garden, :config, original) 17 + else 18 + Application.delete_env(:garden, :config) 19 + end 20 + end) 21 + 22 + {:ok, private_key_pem: elem(Auth.generate_keypair(), 0)} 23 + end 24 + 25 + describe "request_token/3" do 26 + test "returns token on 200 response", %{private_key_pem: pem} do 27 + post_fun = fn _url, _opts -> 28 + {:ok, 29 + %{ 30 + status: 200, 31 + body: %{ 32 + "access_token" => "abc123", 33 + "expires_in" => 3600, 34 + "token_type" => "bearer" 35 + } 36 + }} 37 + end 38 + 39 + assert {:ok, %{access_token: "abc123", expires_in: 3600, token_type: "bearer"}} = 40 + Auth.request_token(@client_id, pem, post_fun) 41 + end 42 + 43 + test "classifies 401 as server rejection", %{private_key_pem: pem} do 44 + post_fun = fn _url, _opts -> 45 + {:ok, %{status: 401, body: %{"error" => "invalid_client"}}} 46 + end 47 + 48 + logs = 49 + capture_log(fn -> 50 + assert {:error, {:server_rejected, 401}} = 51 + Auth.request_token(@client_id, pem, post_fun) 52 + end) 53 + 54 + assert logs =~ "rejected by server" 55 + end 56 + 57 + test "classifies 400 invalid_client as server rejection", %{private_key_pem: pem} do 58 + post_fun = fn _url, _opts -> 59 + {:ok, %{status: 400, body: %{"error" => "invalid_client"}}} 60 + end 61 + 62 + capture_log(fn -> 63 + assert {:error, {:server_rejected, 400}} = 64 + Auth.request_token(@client_id, pem, post_fun) 65 + end) 66 + end 67 + 68 + test "classifies 5xx as server error", %{private_key_pem: pem} do 69 + post_fun = fn _url, _opts -> 70 + {:ok, %{status: 503, body: "service unavailable"}} 71 + end 72 + 73 + capture_log(fn -> 74 + assert {:error, {:server_error, 503}} = Auth.request_token(@client_id, pem, post_fun) 75 + end) 76 + end 77 + 78 + test "classifies transport errors separately from server responses", %{private_key_pem: pem} do 79 + post_fun = fn _url, _opts -> 80 + {:error, %Req.TransportError{reason: :closed}} 81 + end 82 + 83 + logs = 84 + capture_log(fn -> 85 + assert {:error, {:transport_error, :closed}} = 86 + Auth.request_token(@client_id, pem, post_fun) 87 + end) 88 + 89 + assert logs =~ "transport error" 90 + end 91 + 92 + test "wraps unexpected errors as request errors", %{private_key_pem: pem} do 93 + post_fun = fn _url, _opts -> {:error, :boom} end 94 + 95 + capture_log(fn -> 96 + assert {:error, {:request_error, :boom}} = Auth.request_token(@client_id, pem, post_fun) 97 + end) 98 + end 99 + end 100 + end