diff --git a/internal/cmd/deploy.go b/internal/cmd/deploy.go index 01010c89..0a00db62 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -52,6 +52,9 @@ func newDeployCommand() *deployCommand { deployCommand.cmd.Flags().DurationVar(&deployCommand.args.ServiceOptions.WriterAffinityTimeout, "writer-affinity-timeout", server.DefaultWriterAffinityTimeout, "Time after a write before read requests will be routed to readers") deployCommand.cmd.Flags().BoolVar(&deployCommand.args.ServiceOptions.ReadTargetsAcceptWebsockets, "read-target-websockets", false, "Route WebSocket traffic to read targets, when available") + deployCommand.cmd.Flags().DurationVar(&deployCommand.args.ServiceOptions.IdleTimeout, "idle-timeout", 0, "Stop container after this duration of inactivity (0 to disable)") + deployCommand.cmd.Flags().DurationVar(&deployCommand.args.ServiceOptions.IdleWakeTimeout, "idle-wake-timeout", server.DefaultIdleWakeTimeout, "Max time to hold request while waking container") + deployCommand.cmd.Flags().DurationVar(&deployCommand.args.TargetOptions.ResponseTimeout, "target-timeout", server.DefaultTargetTimeout, "Maximum time to wait for the target server to respond when serving requests") deployCommand.cmd.Flags().BoolVar(&deployCommand.args.TargetOptions.BufferRequests, "buffer-requests", false, "Buffer requests before forwarding to target") diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 0a70655e..1c2689ea 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -29,6 +29,7 @@ func newRunCommand() *runCommand { runCommand.cmd.Flags().IntVar(&globalConfig.HttpsPort, "https-port", getEnvInt("HTTPS_PORT", server.DefaultHttpsPort), "Port to serve HTTPS traffic on") runCommand.cmd.Flags().IntVar(&globalConfig.MetricsPort, "metrics-port", getEnvInt("METRICS_PORT", 0), "Publish metrics on the specified port (default zero to disable)") runCommand.cmd.Flags().BoolVar(&globalConfig.HTTP3Enabled, "http3", false, "Enable HTTP/3") + runCommand.cmd.Flags().StringVar(&globalConfig.DockerSocketPath, "docker-socket", getEnvString("DOCKER_SOCKET", server.DefaultDockerSocketPath), "Path to Docker socket") return runCommand } @@ -36,7 +37,7 @@ func newRunCommand() *runCommand { func (c *runCommand) run(cmd *cobra.Command, args []string) error { c.setLogger() - router := server.NewRouter(globalConfig.StatePath()) + router := server.NewRouter(globalConfig.StatePath(), globalConfig.DockerSocketPath) router.RestoreLastSavedState() s := server.NewServer(&globalConfig, router) diff --git a/internal/server/config.go b/internal/server/config.go index 03148253..f99d15c8 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -10,6 +10,7 @@ import ( const ( DefaultHttpPort = 80 DefaultHttpsPort = 443 + DefaultDockerSocketPath = "/var/run/docker.sock" ) type Config struct { @@ -20,6 +21,7 @@ type Config struct { HTTP3Enabled bool AlternateConfigDir string + DockerSocketPath string } func (c Config) SocketPath() string { diff --git a/internal/server/docker_client.go b/internal/server/docker_client.go new file mode 100644 index 00000000..fc087faf --- /dev/null +++ b/internal/server/docker_client.go @@ -0,0 +1,64 @@ +package server + +import ( + "context" + "fmt" + "net" + "net/http" +) + +type DockerClient struct { + httpClient *http.Client +} + +func NewDockerClient(socketPath string) *DockerClient { + return &DockerClient{ + httpClient: &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) + }, + }, + }, + } +} + +func (c *DockerClient) StopContainer(ctx context.Context, name string) error { + url := fmt.Sprintf("http://localhost/v1.41/containers/%s/stop", name) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotModified { + return fmt.Errorf("unexpected status code from docker stop: %d", resp.StatusCode) + } + + return nil +} + +func (c *DockerClient) StartContainer(ctx context.Context, name string) error { + url := fmt.Sprintf("http://localhost/v1.41/containers/%s/start", name) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotModified { + return fmt.Errorf("unexpected status code from docker start: %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/server/docker_client_test.go b/internal/server/docker_client_test.go new file mode 100644 index 00000000..e3c5b4c6 --- /dev/null +++ b/internal/server/docker_client_test.go @@ -0,0 +1,44 @@ +package server + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDockerClient_StopStart(t *testing.T) { + stopCalled := false + startCalled := false + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.41/containers/test-container/stop" { + stopCalled = true + w.WriteHeader(http.StatusNoContent) + } else if r.URL.Path == "/v1.41/containers/test-container/start" { + startCalled = true + w.WriteHeader(http.StatusNoContent) + } + })) + + socketPath := t.TempDir() + "/docker.sock" + l, err := net.Listen("unix", socketPath) + require.NoError(t, err) + server.Listener = l + server.Start() + defer server.Close() + + client := NewDockerClient(socketPath) + + err = client.StopContainer(context.Background(), "test-container") + assert.NoError(t, err) + assert.True(t, stopCalled) + + err = client.StartContainer(context.Background(), "test-container") + assert.NoError(t, err) + assert.True(t, startCalled) +} diff --git a/internal/server/idle_controller.go b/internal/server/idle_controller.go new file mode 100644 index 00000000..7486aa6a --- /dev/null +++ b/internal/server/idle_controller.go @@ -0,0 +1,252 @@ +package server + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + "time" +) + +type IdleState int + +const ( + IdleStateActive IdleState = iota + IdleStateSleeping + IdleStateWaking +) + +func (is IdleState) String() string { + switch is { + case IdleStateActive: + return "active" + case IdleStateSleeping: + return "sleeping" + case IdleStateWaking: + return "waking" + default: + return "" + } +} + +type IdleWaitAction int + +const ( + IdleWaitActionProceed IdleWaitAction = iota + IdleWaitActionTimedOut +) + +type IdleController struct { + State IdleState `json:"state"` + IdleTimeout time.Duration `json:"idle_timeout"` + WakeTimeout time.Duration `json:"wake_timeout"` + ContainerNames []string `json:"container_names"` + + serviceName string + docker *DockerClient + lb *LoadBalancer + + lock sync.RWMutex + lastRequestAt time.Time + wakeChan chan bool + closeChan chan bool + disabled bool +} + +func NewIdleController(serviceName string, idleTimeout, wakeTimeout time.Duration, containerNames []string, docker *DockerClient, lb *LoadBalancer) *IdleController { + ic := &IdleController{ + State: IdleStateActive, + IdleTimeout: idleTimeout, + WakeTimeout: wakeTimeout, + ContainerNames: containerNames, + serviceName: serviceName, + docker: docker, + lb: lb, + lastRequestAt: time.Now(), + closeChan: make(chan bool), + } + + go ic.run() + return ic +} + +func (ic *IdleController) UnmarshalJSON(data []byte) error { + type alias IdleController + aux := &struct { + *alias + }{ + alias: (*alias)(ic), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + ic.lastRequestAt = time.Now() + ic.closeChan = make(chan bool) + + go ic.run() + return nil +} + +func (ic *IdleController) TrackActivity() { + ic.lock.Lock() + defer ic.lock.Unlock() + + ic.lastRequestAt = time.Now() +} + +func (ic *IdleController) GetState() IdleState { + ic.lock.RLock() + defer ic.lock.RUnlock() + return ic.State +} + +func (ic *IdleController) WaitIfSleeping() IdleWaitAction { + ic.lock.RLock() + state := ic.State + wakeChan := ic.wakeChan + wakeTimeout := ic.WakeTimeout + ic.lock.RUnlock() + + if state == IdleStateActive { + return IdleWaitActionProceed + } + + if state == IdleStateSleeping { + ic.wake() + // Re-read wakeChan + ic.lock.RLock() + wakeChan = ic.wakeChan + ic.lock.RUnlock() + } + + if wakeChan == nil { + return IdleWaitActionProceed + } + + select { + case <-wakeChan: + return IdleWaitActionProceed + case <-time.After(wakeTimeout): + return IdleWaitActionTimedOut + } +} + +func (ic *IdleController) UpdateContainers(names []string) { + ic.lock.Lock() + defer ic.lock.Unlock() + + ic.ContainerNames = names + ic.lastRequestAt = time.Now() + + if ic.State != IdleStateActive { + ic.State = IdleStateActive + if ic.wakeChan != nil { + close(ic.wakeChan) + ic.wakeChan = nil + } + } +} + +func (ic *IdleController) Disable() { + ic.lock.Lock() + defer ic.lock.Unlock() + ic.disabled = true +} + +func (ic *IdleController) Enable() { + ic.lock.Lock() + defer ic.lock.Unlock() + ic.disabled = false + ic.lastRequestAt = time.Now() +} + +func (ic *IdleController) Close() { + close(ic.closeChan) +} + +func (ic *IdleController) run() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ic.closeChan: + return + case <-ticker.C: + ic.checkIdle() + } + } +} + +func (ic *IdleController) checkIdle() { + ic.lock.Lock() + if ic.disabled || ic.State != IdleStateActive || ic.IdleTimeout <= 0 { + ic.lock.Unlock() + return + } + + if time.Since(ic.lastRequestAt) > ic.IdleTimeout { + ic.State = IdleStateSleeping + ic.wakeChan = make(chan bool) + containerNames := ic.ContainerNames + ic.lock.Unlock() + + slog.Info("Service is idle, stopping containers", "service", ic.serviceName, "containers", containerNames) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + for _, name := range containerNames { + if err := ic.docker.StopContainer(ctx, name); err != nil { + slog.Error("Failed to stop idle container", "service", ic.serviceName, "container", name, "error", err) + } + } + } else { + ic.lock.Unlock() + } +} + +func (ic *IdleController) wake() { + ic.lock.Lock() + if ic.State != IdleStateSleeping { + ic.lock.Unlock() + return + } + + ic.State = IdleStateWaking + containerNames := ic.ContainerNames + ic.lock.Unlock() + + slog.Info("Service waking up, starting containers", "service", ic.serviceName, "containers", containerNames) + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), ic.WakeTimeout) + defer cancel() + + var wg sync.WaitGroup + wg.Add(len(containerNames)) + for _, name := range containerNames { + go func(n string) { + defer wg.Done() + if err := ic.docker.StartContainer(ctx, n); err != nil { + slog.Error("Failed to start container during wake", "service", ic.serviceName, "container", n, "error", err) + } + }(name) + } + wg.Wait() + + // Wait until healthy + err := ic.lb.WaitUntilHealthy(ic.WakeTimeout) + if err != nil { + slog.Error("Service failed to become healthy after wake", "service", ic.serviceName, "error", err) + } + + ic.lock.Lock() + ic.State = IdleStateActive + ic.lastRequestAt = time.Now() + close(ic.wakeChan) + ic.wakeChan = nil + ic.lock.Unlock() + }() +} diff --git a/internal/server/idle_controller_test.go b/internal/server/idle_controller_test.go new file mode 100644 index 00000000..11dc5215 --- /dev/null +++ b/internal/server/idle_controller_test.go @@ -0,0 +1,75 @@ +package server + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIdleController_IdleAndWake(t *testing.T) { + stopCalled := make(chan bool, 1) + startCalled := make(chan bool, 1) + + dockerServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.41/containers/test-container/stop" { + stopCalled <- true + w.WriteHeader(http.StatusNoContent) + } else if r.URL.Path == "/v1.41/containers/test-container/start" { + startCalled <- true + w.WriteHeader(http.StatusNoContent) + } + })) + + socketPath := t.TempDir() + "/docker.sock" + l, err := net.Listen("unix", socketPath) + require.NoError(t, err) + dockerServer.Listener = l + dockerServer.Start() + defer dockerServer.Close() + + dockerClient := NewDockerClient(socketPath) + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + defer backend.Close() + + target, _ := NewTarget(backend.URL[7:], defaultTargetOptions) + tl := TargetList{target} + lb := NewLoadBalancer(tl, 0, false) + lb.MarkAllHealthy() + + ic := NewIdleController("test", 100*time.Millisecond, time.Second, []string{"test-container"}, dockerClient, lb) + defer ic.Close() + + // Initial state: active + assert.Equal(t, IdleStateActive, ic.GetState()) + + // Wait for idle to trigger + select { + case <-stopCalled: + case <-time.After(time.Second): + t.Fatal("Timeout waiting for container to be stopped") + } + + assert.Equal(t, IdleStateSleeping, ic.GetState()) + + // Wake up on request + action := ic.WaitIfSleeping() + assert.Equal(t, IdleWaitActionProceed, action) + + select { + case <-startCalled: + case <-time.After(time.Second): + t.Fatal("Timeout waiting for container to be started") + } + + assert.Equal(t, IdleStateActive, ic.GetState()) +} diff --git a/internal/server/router.go b/internal/server/router.go index 051f0be8..027da834 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -36,9 +36,10 @@ func RoutingContext(r *http.Request) *routingContext { } type Router struct { - statePath string - services *ServiceMap - serviceLock sync.RWMutex + statePath string + dockerClient *DockerClient + services *ServiceMap + serviceLock sync.RWMutex } type ServiceDescription struct { @@ -51,10 +52,11 @@ type ServiceDescription struct { type ServiceDescriptionMap map[string]ServiceDescription -func NewRouter(statePath string) *Router { +func NewRouter(statePath string, dockerSocketPath string) *Router { return &Router{ - statePath: statePath, - services: NewServiceMap(), + statePath: statePath, + dockerClient: NewDockerClient(dockerSocketPath), + services: NewServiceMap(), } } @@ -80,6 +82,8 @@ func (r *Router) RestoreLastSavedState() error { r.withWriteLock(func() error { r.services = NewServiceMap() for _, service := range services { + service.docker = r.dockerClient + service.initialize(service.options, service.targetOptions) r.services.Set(service) } @@ -244,12 +248,20 @@ func (r *Router) ListActiveServices() ServiceDescriptionMap { path := strings.Join(service.options.PathPrefixes, ",") target := strings.Join(service.active.Targets().Names(), ",") + state := service.pauseController.GetState().String() + if service.idleController != nil && service.pauseController.GetState() == PauseStateRunning { + icState := service.idleController.GetState() + if icState != IdleStateActive { + state = icState.String() + } + } + result[name] = ServiceDescription{ Host: host, Path: path, Target: target, TLS: service.options.TLSEnabled, - State: service.pauseController.GetState().String(), + State: state, } } } @@ -290,9 +302,10 @@ func (r *Router) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, e func (r *Router) createOrUpdateService(name string, options ServiceOptions, targetOptions TargetOptions) (*Service, error) { service := r.services.Get(name) if service == nil { - return NewService(name, options, targetOptions) + return NewService(name, options, targetOptions, r.dockerClient) } + service.docker = r.dockerClient err := service.UpdateOptions(options, targetOptions) return service, err } diff --git a/internal/server/router_test.go b/internal/server/router_test.go index c1252b3c..5b17d2e8 100644 --- a/internal/server/router_test.go +++ b/internal/server/router_test.go @@ -732,7 +732,7 @@ func TestRouter_RestoreLastSavedState(t *testing.T) { _, second := testBackend(t, "second", http.StatusOK) _, third := testBackend(t, "third", http.StatusOK) - router := NewRouter(statePath) + router := NewRouter(statePath, DefaultDockerSocketPath) require.NoError(t, router.DeployService("default", []string{first}, defaultEmptyReaders, defaultServiceOptions, defaultTargetOptions, defaultDeploymentOptions)) serviceOptions := defaultServiceOptions @@ -756,7 +756,7 @@ func TestRouter_RestoreLastSavedState(t *testing.T) { assert.Equal(t, http.StatusOK, statusCode) assert.Equal(t, "third", body) - router = NewRouter(statePath) + router = NewRouter(statePath, DefaultDockerSocketPath) router.RestoreLastSavedState() statusCode, body = sendGETRequest(router, "http://something.example.com/") @@ -775,7 +775,7 @@ func TestRouter_RestoreLastSavedState(t *testing.T) { func testRouter(t *testing.T) *Router { statePath := filepath.Join(t.TempDir(), "state.json") - return NewRouter(statePath) + return NewRouter(statePath, DefaultDockerSocketPath) } func sendGETRequest(router *Router, url string) (int, string) { diff --git a/internal/server/service.go b/internal/server/service.go index 56953137..076d5310 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -45,7 +45,9 @@ const ( DefaultTargetTimeout = time.Second * 30 DefaultMaxMemoryBufferSize = 1 * MB DefaultMaxRequestBodySize = 0 - DefaultMaxResponseBodySize = 0 + DefaultMaxResponseBodySize = 0 + + DefaultIdleWakeTimeout = 30 * time.Second DefaultStopMessage = "" ) @@ -91,6 +93,8 @@ type ServiceOptions struct { StripPrefix bool `json:"strip_prefix"` WriterAffinityTimeout time.Duration `json:"writer_affinity_timeout"` ReadTargetsAcceptWebsockets bool `json:"read_targets_accept_websockets"` + IdleTimeout time.Duration `json:"idle_timeout"` + IdleWakeTimeout time.Duration `json:"idle_wake_timeout"` } func (so *ServiceOptions) Normalize() { @@ -133,15 +137,19 @@ type Service struct { serviceLock sync.RWMutex pauseController *PauseController + idleController *IdleController rolloutController *RolloutController + docker *DockerClient + certManager CertManager middleware http.Handler } -func NewService(name string, options ServiceOptions, targetOptions TargetOptions) (*Service, error) { +func NewService(name string, options ServiceOptions, targetOptions TargetOptions, docker *DockerClient) (*Service, error) { service := &Service{ name: name, + docker: docker, pauseController: NewPauseController(), } @@ -156,6 +164,10 @@ func (s *Service) UpdateOptions(options ServiceOptions, targetOptions TargetOpti } func (s *Service) Dispose() { + if s.idleController != nil { + s.idleController.Close() + } + s.active.Dispose() if s.rollout != nil { s.rollout.Dispose() @@ -174,6 +186,10 @@ func (s *Service) UpdateLoadBalancer(lb *LoadBalancer, slot TargetSlot) *LoadBal } else { replaced = s.active s.active = lb + if s.idleController != nil { + s.idleController.lb = lb + s.idleController.UpdateContainers(lb.WriteTargets().Names()) + } } return replaced @@ -205,6 +221,10 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { metrics.Tracker.AddInflightRequest(s.name) defer metrics.Tracker.SubtractInflightRequest(s.name) + if s.idleController != nil && !s.targetOptions.IsHealthCheckRequest(r) { + s.idleController.TrackActivity() + } + s.middleware.ServeHTTP(w, r) } @@ -217,6 +237,7 @@ type marshalledService struct { RolloutTargets []string `json:"rollout_targets"` RolloutReaders []string `json:"rollout_readers"` PauseController *PauseController `json:"pause_controller"` + IdleController *IdleController `json:"idle_controller"` RolloutController *RolloutController `json:"rollout_controller"` LegacyActiveTarget string `json:"active_target,omitempty"` @@ -242,6 +263,7 @@ func (s *Service) MarshalJSON() ([]byte, error) { Options: s.options, TargetOptions: s.targetOptions, PauseController: s.pauseController, + IdleController: s.idleController, RolloutController: s.rolloutController, }) } @@ -270,6 +292,7 @@ func (s *Service) UnmarshalJSON(data []byte) error { s.name = ms.Name s.pauseController = ms.PauseController + s.idleController = ms.IdleController s.rolloutController = ms.RolloutController activeTargets, err := NewTargetList(ms.ActiveTargets, ms.ActiveReaders, ms.TargetOptions) @@ -292,6 +315,10 @@ func (s *Service) UnmarshalJSON(data []byte) error { } func (s *Service) Stop(drainTimeout time.Duration, message string) error { + if s.idleController != nil { + s.idleController.Disable() + } + err := s.pauseController.Stop(message) if err != nil { return err @@ -305,6 +332,10 @@ func (s *Service) Stop(drainTimeout time.Duration, message string) error { } func (s *Service) Pause(drainTimeout time.Duration, pauseTimeout time.Duration) error { + if s.idleController != nil { + s.idleController.Disable() + } + err := s.pauseController.Pause(pauseTimeout) if err != nil { return err @@ -318,6 +349,10 @@ func (s *Service) Pause(drainTimeout time.Duration, pauseTimeout time.Duration) } func (s *Service) Resume() error { + if s.idleController != nil { + s.idleController.Enable() + } + err := s.pauseController.Resume() if err != nil { return err @@ -345,6 +380,22 @@ func (s *Service) initialize(options ServiceOptions, targetOptions TargetOptions s.certManager = certManager s.middleware = middleware + if s.options.IdleTimeout > 0 { + if s.idleController == nil { + s.idleController = NewIdleController(s.name, s.options.IdleTimeout, s.options.IdleWakeTimeout, s.activeTargets(), s.docker, s.active) + } else { + s.idleController.docker = s.docker + s.idleController.lb = s.active + s.idleController.serviceName = s.name + s.idleController.IdleTimeout = s.options.IdleTimeout + s.idleController.WakeTimeout = s.options.IdleWakeTimeout + s.idleController.UpdateContainers(s.activeTargets()) + } + } else if s.idleController != nil { + s.idleController.Close() + s.idleController = nil + } + return nil } @@ -371,6 +422,13 @@ func (s *Service) loadBalancerForRequest(req *http.Request) *LoadBalancer { return lb } +func (s *Service) activeTargets() []string { + if s.active == nil { + return nil + } + return s.active.WriteTargets().Names() +} + func (s *Service) servesRootPath() bool { return slices.Contains(s.options.PathPrefixes, rootPath) } @@ -438,12 +496,43 @@ func (s *Service) serviceRequestWithTarget(w http.ResponseWriter, r *http.Reques return } + if s.handleIdleRequests(w, r) { + return + } + sendRequest := s.startLoadBalancerRequest(w, r) if sendRequest != nil { sendRequest() } } +func (s *Service) handleIdleRequests(w http.ResponseWriter, r *http.Request) bool { + if s.idleController == nil { + return false + } + + if s.targetOptions.IsHealthCheckRequest(r) { + // Health checks should not wake the service. + // If it's sleeping, just return 200. + icState := s.idleController.GetState() + if icState != IdleStateActive { + w.WriteHeader(http.StatusOK) + return true + } + return false + } + + action := s.idleController.WaitIfSleeping() + if action == IdleWaitActionTimedOut { + slog.Warn("Rejecting request due to idle wake timeout", "service", s.name, "path", r.URL.Path) + w.Header().Set("Retry-After", "10") + SetErrorResponse(w, r, http.StatusServiceUnavailable, nil) + return true + } + + return false +} + func (s *Service) startLoadBalancerRequest(w http.ResponseWriter, r *http.Request) func() { s.serviceLock.RLock() defer s.serviceLock.RUnlock() diff --git a/internal/server/service_test.go b/internal/server/service_test.go index 6dd39d46..000f540a 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -232,7 +232,7 @@ func testCreateServiceWithHandler(t *testing.T, options ServiceOptions, targetOp target, err := NewTarget(serverURL.Host, targetOptions) require.NoError(t, err) - service, err := NewService("test", options, targetOptions) + service, err := NewService("test", options, targetOptions, NewDockerClient(DefaultDockerSocketPath)) require.NoError(t, err) service.UpdateLoadBalancer(NewLoadBalancer(TargetList{target}, DefaultWriterAffinityTimeout, false), TargetSlotActive) diff --git a/internal/server/testing.go b/internal/server/testing.go index 99d12f52..74926e8f 100644 --- a/internal/server/testing.go +++ b/internal/server/testing.go @@ -79,7 +79,7 @@ func testServer(t testing.TB, http3Enabled bool) *Server { AlternateConfigDir: t.TempDir(), HTTP3Enabled: http3Enabled, } - router := NewRouter(config.StatePath()) + router := NewRouter(config.StatePath(), DefaultDockerSocketPath) server := NewServer(config, router) err := server.Start() require.NoError(t, err)