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.
This commit is contained in:
Paweł Banaszewski
2026-08-19 13:19:20 +02:00
committed by GitHub
parent 8405bbb26c
commit 63641b98c8
3 changed files with 92 additions and 2 deletions
+11 -2
View File
@@ -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:
+74
View File
@@ -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()