diff options
author | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2025-08-02 20:55:11 +0530 |
---|---|---|
committer | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2025-08-02 20:55:11 +0530 |
commit | ce6cf13c2d67c3368251d1eea5593198f5021330 (patch) | |
tree | d4c2347fd45fce395bf22a30e423697494d4284f /http | |
download | kit-f56c6730f284513e728f9337d1c92f91938bea46.tar kit-f56c6730f284513e728f9337d1c92f91938bea46.tar.gz kit-f56c6730f284513e728f9337d1c92f91938bea46.tar.bz2 kit-f56c6730f284513e728f9337d1c92f91938bea46.tar.lz kit-f56c6730f284513e728f9337d1c92f91938bea46.tar.xz kit-f56c6730f284513e728f9337d1c92f91938bea46.tar.zst kit-f56c6730f284513e728f9337d1c92f91938bea46.zip |
Initial Commitv0.1.0
Diffstat (limited to '')
-rw-r--r-- | http/server.go | 146 | ||||
-rw-r--r-- | http/server_test.go | 159 |
2 files changed, 305 insertions, 0 deletions
diff --git a/http/server.go b/http/server.go new file mode 100644 index 0000000..e13bc67 --- /dev/null +++ b/http/server.go @@ -0,0 +1,146 @@ +// Package http provides utilities for managing HTTP servers and listeners. +// +// It supports opening multiple network listeners from configuration, +// running multiple HTTP servers concurrently with graceful shutdown, +// and integrates structured logging with context. +// +// This package is designed to be used with context cancellation for concurrency control. + +package http + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "sync" + "time" + + "go.sudomsg.com/kit/logging" + "golang.org/x/sync/errgroup" +) + +// Listeners is a slice of net.Listener interfaces representing multiple network listeners. +type Listeners []net.Listener + +// CloseAll closes all listeners in the slice. +// It aggregates all errors returned by individual Close calls using errors.Join. +// +// If no errors occur, it returns nil. +func (ls Listeners) CloseAll() error { + var errs []error + for _, l := range ls { + if err := l.Close(); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +// ServerConfig defines a single network listener configuration. +// +// Network is the network type, e.g., "tcp". +// Address is the socket address, e.g., ":8080". +type ServerConfig struct { + Network string `toml:"network"` + Address string `toml:"network"` +} + +// OpenConfigListeners opens network listeners as specified by the provided ServerConfig slice. +// +// It attempts to open all listeners concurrently and returns them if all succeed. +// +// If any listener fails to open, it closes all previously opened listeners and returns an error. +// +// The context controls cancellation of the opening process. +func OpenConfigListners(ctx context.Context, config []ServerConfig) (Listeners, error) { + lns := make(Listeners, 0, len(config)) + var mu sync.Mutex + g, ctx := errgroup.WithContext(ctx) + var lc net.ListenConfig + for _, cfg := range config { + g.Go(func() error { + network := cfg.Network + if network == "" { + network = "tcp" + } + ln, err := lc.Listen(ctx, network, cfg.Address) + if err != nil { + return fmt.Errorf("failed to listen on %s %s: %w", network, cfg.Address, err) + } + + mu.Lock() + lns = append(lns, ln) + mu.Unlock() + return nil + }) + } + if err := g.Wait(); err != nil { + _ = lns.CloseAll() + return nil, err + } + return lns, nil +} + +func OpenListeners(ctx context.Context, config []ServerConfig) (Listeners, error) { + return OpenConfigListners(ctx, config) +} + +// RunHTTPServers runs HTTP servers concurrently on all provided listeners. +// +// The provided handler is used for all servers. +// Servers respond to context cancellation by performing a graceful shutdown with a timeout of 10 seconds. +// +// Logging is performed using the slog.Logger extracted from context. +// Each server logs startup, shutdown, and errors. +// +// This function blocks until all servers have stopped or an error occurs. +func RunHTTPServers(ctx context.Context, lns Listeners, handler http.Handler) error { + g, ctx := errgroup.WithContext(ctx) + + for _, ln := range lns { + g.Go(func() error { + logger, ctx := logging.With(ctx, "address", ln.Addr) + + srv := &http.Server{ + Addr: ln.Addr().String(), + Handler: handler, + BaseContext: func(l net.Listener) context.Context { + return ctx + }, + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + } + + logger.Log(ctx, slog.LevelInfo, "HTTP server serving") + + if err := httpServeContext(ctx, srv, ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("HTTP server Serve Error: %w", err) + } + return nil + }) + + } + return g.Wait() +} + +func httpServeContext(ctx context.Context, srv *http.Server, ln net.Listener) error { + logger := logging.FromContext(ctx) + go func() { + <-ctx.Done() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err := srv.Shutdown(ctx) + if err != nil { + logger.Log(ctx, slog.LevelWarn, "HTTP server Shutdown Error", slog.Any("error", err)) + } else { + logger.Log(ctx, slog.LevelInfo, "HTTP Server Shutdown Complete") + } + }() + return srv.Serve(ln) +} diff --git a/http/server_test.go b/http/server_test.go new file mode 100644 index 0000000..7138c73 --- /dev/null +++ b/http/server_test.go @@ -0,0 +1,159 @@ +package http_test + +import ( + "context" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + httpServer "go.sudomsg.com/kit/http" +) + +// helper to find free TCP ports +func newListener(tb testing.TB) net.Listener { + tb.Helper() + + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + tb.Fatalf("failed to open listener: %v", err) + } + return ln +} + +func TestOpenConfigListeners(t *testing.T) { + t.Parallel() + t.Run("successful config", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + cfg := []httpServer.ServerConfig{ + {Network: "tcp", Address: "localhost:0"}, + {Network: "tcp", Address: "localhost:0"}, + } + + lns, err := httpServer.OpenConfigListners(ctx, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer lns.CloseAll() + + if got := len(lns); got != len(cfg) { + t.Errorf("expected %d listeners, got %d", len(cfg), got) + } + }) + + t.Run("port conflict triggers cleanup", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + conflict := newListener(t) + defer conflict.Close() + + cfg := []httpServer.ServerConfig{ + {Network: "tcp", Address: "localhost:0"}, + {Network: "tcp", Address: conflict.Addr().String()}, // will fail + } + + lns, err := httpServer.OpenConfigListners(ctx, cfg) + if err == nil { + defer lns.CloseAll() + t.Fatal("expected error due to conflict, got nil") + } + }) +} + +func TestCloseAll(t *testing.T) { + t.Run("closes all listeners", func(t *testing.T) { + t.Parallel() + ln1 := newListener(t) + ln2 := newListener(t) + + ls := httpServer.Listeners{ln1, ln2} + err := ls.CloseAll() + if err != nil { + t.Errorf("unexpected error from CloseAll: %v", err) + } + + for _, ln := range ls { + if _, err := ln.Accept(); err == nil { + t.Error("expected listener to be closed, but Accept succeeded") + } + } + }) +} + +func TestRunHTTPServers(t *testing.T) { + t.Parallel() + t.Run("basic serve and shutdown", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "hello") + }) + + ln := newListener(t) + addr := ln.Addr().String() + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + // Wait for server to be ready + time.Sleep(200 * time.Millisecond) + + r, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr, nil) + resp, err := http.DefaultClient.Do(r) + if err != nil { + t.Errorf("http.Do error: %v", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if got := strings.TrimSpace(string(body)); got != "hello" { + t.Errorf("unexpected response body: %q", got) + } + + cancel() // shutdown the server + }() + + err := httpServer.RunHTTPServers(ctx, httpServer.Listeners{ln}, handler) + if err != nil { + t.Fatalf("RunHTTPServers failed: %v", err) + } + }) +} + +func TestInvalidConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config httpServer.ServerConfig + }{ + { + name: "invalid network", + config: httpServer.ServerConfig{Network: "invalid", Address: "localhost:0"}, + }, + { + name: "invalid address", + config: httpServer.ServerConfig{Network: "tcp", Address: "::::"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + _, err := httpServer.OpenConfigListners(ctx, []httpServer.ServerConfig{tt.config}) + if err == nil { + t.Fatal("OpenConfigListners() expected error, got nil") + } + }) + } +} |