Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/api/main_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestDatabaseInitialization(t *testing.T) {
Host: "localhost",
Port: "8082",
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
WriteTimeout: time.Duration(0),
ShutdownTimeout: 30 * time.Second,
},
Logging: config.LogConfig{
Expand Down
4 changes: 2 additions & 2 deletions backend/api/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func mockLoadConfig() (*config.Config, error) {
Host: "localhost",
Port: "8082",
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
WriteTimeout: time.Duration(0),
ShutdownTimeout: 30 * time.Second,
},
Logging: config.LogConfig{
Expand Down Expand Up @@ -288,5 +288,5 @@ func TestServerConfiguration(t *testing.T) {

assert.Equal(t, addr, server.Addr)
assert.Equal(t, 10*time.Second, server.ReadTimeout)
assert.Equal(t, 10*time.Second, server.WriteTimeout)
assert.Equal(t, time.Duration(0), server.WriteTimeout)
}
26 changes: 20 additions & 6 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ const (
defaultMaxIdleConns int32 = 5
defaultConnMaxLifetime = 5 * time.Minute
defaultReadTimeout = 10 * time.Second
defaultWriteTimeout = 10 * time.Second
defaultIdleTimeout = 30 * time.Second
defaultShutdownTimeout = 30 * time.Second
// defaultWriteTimeout is 0 (disabled at the server level) because per-write
// deadlines are enforced in the WebSocket write pump (websocket/client.go).
// A non-zero server-level write timeout would prematurely terminate idle
// WebSocket connections.
defaultWriteTimeout = time.Duration(0)
defaultIdleTimeout = 30 * time.Second
defaultShutdownTimeout = 30 * time.Second
)

// CORSConfig holds CORS configuration
Expand Down Expand Up @@ -83,7 +87,15 @@ type AzureTableConfig struct {
//nolint:govet // Struct field alignment has been optimized for time.Duration fields
type ServerConfig struct {
// 8-byte aligned fields first
ReadTimeout time.Duration
ReadTimeout time.Duration
// WriteTimeout is 0 (disabled) at the server level. Per-write deadlines are
// enforced inside the WebSocket write pump (websocket/client.go), so a
// server-level write timeout must be disabled to prevent premature
// termination of idle WebSocket connections. Standard REST handlers complete
// quickly in practice, but setting WriteTimeout to 0 does mean slow or
// stalled clients can hold a connection indefinitely. Operators who want
// protection against slow clients should set SERVER_WRITE_TIMEOUT to a
// positive value (e.g. 30s); per-handler context deadlines can also be applied.
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownTimeout time.Duration
Expand Down Expand Up @@ -194,8 +206,10 @@ func (c *ServerConfig) Validate() error {
return errors.New("read timeout must be positive")
}

if c.WriteTimeout <= 0 {
return errors.New("write timeout must be positive")
// WriteTimeout of 0 is valid — it disables the server-level write timeout
// (per-write deadlines are handled in the WebSocket write pump instead).
if c.WriteTimeout < 0 {
return errors.New("write timeout must be non-negative (0 to disable)")
}

if c.IdleTimeout <= 0 {
Expand Down
59 changes: 57 additions & 2 deletions backend/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,8 @@ func TestLoadConfig(t *testing.T) {
assert.Empty(t, config.Server.Host)
assert.Equal(t, "8081", config.Server.Port)
assert.Equal(t, 10*time.Second, config.Server.ReadTimeout)
assert.Equal(t, 10*time.Second, config.Server.WriteTimeout)
assert.Equal(t, time.Duration(0), config.Server.WriteTimeout) // disabled; per-write deadlines enforced in WebSocket write pump assert.Equal(t, 30*time.Second, config.Server.IdleTimeout)
assert.Equal(t, 30*time.Second, config.Server.ShutdownTimeout)

// Check default logging config
assert.Equal(t, "info", config.Logging.Level)
assert.Empty(t, config.Logging.File)
Expand Down Expand Up @@ -281,6 +280,62 @@ func TestConfigValidate(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "server config")
})

t.Run("zero WriteTimeout passes validation", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
App: config.AppConfig{
Name: "myapp",
Environment: "production",
},
Database: config.DatabaseConfig{
Host: "localhost",
Port: "3306",
User: "user",
DBName: "dbname",
MaxOpenConns: 10,
MaxIdleConns: 5,
ConnMaxLifetime: 1 * time.Minute,
},
Server: config.ServerConfig{
Port: "8080",
ReadTimeout: 5 * time.Second,
WriteTimeout: 0, // disabled at server level; per-write deadlines in WebSocket pump
IdleTimeout: 30 * time.Second,
ShutdownTimeout: 10 * time.Second,
},
}
assert.NoError(t, cfg.Validate())
})

t.Run("negative WriteTimeout fails validation", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
App: config.AppConfig{
Name: "myapp",
Environment: "production",
},
Database: config.DatabaseConfig{
Host: "localhost",
Port: "3306",
User: "user",
DBName: "dbname",
MaxOpenConns: 10,
MaxIdleConns: 5,
ConnMaxLifetime: 1 * time.Minute,
},
Server: config.ServerConfig{
Port: "8080",
ReadTimeout: 5 * time.Second,
WriteTimeout: -1 * time.Second,
IdleTimeout: 30 * time.Second,
ShutdownTimeout: 10 * time.Second,
},
}
err := cfg.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "write timeout")
})
}
func TestAzureTableConfigValidate(t *testing.T) {
t.Parallel()
Expand Down