From 63641b98c827076de4d5a2383ba3d908e92edc08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Wed, 19 Aug 2026 13:19:20 +0200 Subject: [PATCH] fix: treat a missing serve endpoint as a fatal dial error (#27864) Adds 404 as a terminal error for establishing DRPC connection. A standalone AI Gateway pointed at a coderd that does not expose `/api/v2/ai-gateway/serve` gets a 404, which the connect loop classified as transient and retried forever. Redialing cannot fix a missing endpoint. 404 now is treated as terminal handshake failure. `--url` is expected to point directly at coderd, so a 404 from an intermediary is not distinguished. Refs https://linear.app/codercom/issue/AIGOV-320/write-connection-tests --- Generated with Coder Agents. --- coderd/aibridged/aibridged.go | 13 ++++- coderd/aibridged/aibridged_test.go | 74 ++++++++++++++++++++++++++ docs/ai-coder/ai-gateway/standalone.md | 7 +++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/coderd/aibridged/aibridged.go b/coderd/aibridged/aibridged.go index 4bc433bf47..5e7baf6a6d 100644 --- a/coderd/aibridged/aibridged.go +++ b/coderd/aibridged/aibridged.go @@ -112,9 +112,18 @@ connectLoop: switch sdkErr.StatusCode() { // These statuses are terminal failures from the /api/v2/ai-gateway/serve // handshake: wrong gateway key, incompatible API version, or entitlement failure. - case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden: + // + // 404 means this coderd does not expose the AI Gateway serve + // endpoint (older version); retrying cannot succeed. The URL is + // expected to point directly at coderd, so a 404 from an + // intermediary is not distinguished. + case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: err = xerrors.Errorf("dial coderd: %w", err) - s.logger.Error(s.lifecycleCtx, "fatal error dialing coderd", slog.Error(err)) + s.logger.Error(s.lifecycleCtx, "fatal error dialing coderd", + slog.Error(err), + slog.F("status_code", sdkErr.StatusCode()), + slog.F("url", sdkErr.URL()), + ) s.cancelFn(err) return default: diff --git a/coderd/aibridged/aibridged_test.go b/coderd/aibridged/aibridged_test.go index 217d1851e2..9fadf22379 100644 --- a/coderd/aibridged/aibridged_test.go +++ b/coderd/aibridged/aibridged_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "sync/atomic" "testing" @@ -100,6 +101,10 @@ func sdkError(status int, message string) error { StatusCode: status, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewBufferString(`{"message":"` + message + `"}`)), + Request: &http.Request{ + Method: http.MethodGet, + URL: &url.URL{Scheme: "https", Host: "example.com", Path: "/api/v2/ai-gateway/serve"}, + }, }) } @@ -128,6 +133,75 @@ func TestClient_TransientDialErrorRetries(t *testing.T) { require.Equal(t, int32(2), calls.Load()) } +// TestClient_FatalDialErrors pins which /api/v2/ai-gateway/serve rejections end +// the daemon's lifecycle instead of being retried. A status that cannot be +// fixed by redialing must stop the connect loop after a single attempt, so an +// operator sees the failure instead of an endless retry. +func TestClient_FatalDialErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + fatal bool + }{ + {name: "IncompatibleVersion", status: http.StatusBadRequest, fatal: true}, + {name: "InvalidKey", status: http.StatusUnauthorized, fatal: true}, + {name: "MissingEntitlement", status: http.StatusForbidden, fatal: true}, + {name: "EndpointUnsupported", status: http.StatusNotFound, fatal: true}, + {name: "CoderdUnavailable", status: http.StatusServiceUnavailable, fatal: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + ctrl := gomock.NewController(t) + pool := mock.NewMockPooler(ctrl) + pool.EXPECT().Shutdown(gomock.Any()).MinTimes(1).Return(nil) + dialFc := func(context.Context) (aibridged.DRPCClient, error) { + calls.Add(1) + return nil, sdkError(tc.status, "dial rejected") + } + + srv, err := aibridged.New(t.Context(), pool, dialFc, slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), testTracer) + require.NoError(t, err) + t.Cleanup(func() { _ = srv.Shutdown(context.Background()) }) + + ctx := testutil.Context(t, testutil.WaitShort) + if !tc.fatal { + // A transient status keeps the loop redialing and the lifecycle open. + require.Eventually(t, func() bool { + return calls.Load() > 1 + }, testutil.WaitShort, testutil.IntervalFast, "a transient status must be retried") + require.NoError(t, srv.Err()) + require.False(t, srv.Ready()) + return + } + + select { + case <-srv.Done(): + case <-ctx.Done(): + t.Fatalf("daemon lifecycle did not end: %v", ctx.Err()) + } + require.ErrorContains(t, srv.Err(), "dial coderd") + require.ErrorContains(t, srv.Err(), "dial rejected") + var gotSDKErr *codersdk.Error + require.ErrorAs(t, srv.Err(), &gotSDKErr) + require.Equal(t, tc.status, gotSDKErr.StatusCode()) + require.Equal(t, "https://example.com/api/v2/ai-gateway/serve", gotSDKErr.URL()) + require.False(t, srv.Ready()) + require.Equal(t, int32(1), calls.Load(), "a fatal status must not be retried") + + // Requests arriving after the fatal exit are told the daemon is gone + // instead of waiting for a connection that will never come. + _, err = srv.Client(ctx) + require.ErrorContains(t, err, "dial coderd") + }) + } +} + func TestServeHTTP_FailureModes(t *testing.T) { t.Parallel() diff --git a/docs/ai-coder/ai-gateway/standalone.md b/docs/ai-coder/ai-gateway/standalone.md index d238b7cdd7..22d9283bb0 100644 --- a/docs/ai-coder/ai-gateway/standalone.md +++ b/docs/ai-coder/ai-gateway/standalone.md @@ -310,6 +310,13 @@ The standalone Gateway image does not need to match the `coderd` image version e The components can connect when their AI Gateway API versions are compatible, and `coderd` checks compatibility whenever a Gateway replica connects. A replica may run at the same API version as `coderd` or an earlier minor version of the same major version, but never a newer one. +The Gateway treats certain handshake failures from `/api/v2/ai-gateway/serve` as fatal and exits rather than retrying: + +- HTTP 400: incompatible API version. +- HTTP 401: invalid Gateway key. +- HTTP 403: missing entitlement. +- HTTP 404: endpoint not found. `coderd` may be too old and not expose `/serve` endpoint. + Sequence changes that move both components: - To upgrade, upgrade `coderd` first, then roll out the standalone Gateway release.