mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user