Skip to content

Commit d266fad

Browse files
committed
feat: add MODBUS_REQUEST_DELAY for upstream request pacing
Add configurable delay between upstream Modbus requests to prevent overwhelming slow devices. The delay is only applied after successful requests and is logged at DEBUG level.
1 parent 051e2fb commit d266fad

File tree

6 files changed

+52
-9
lines changed

6 files changed

+52
-9
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ All configuration is via environment variables:
3434
| `MODBUS_CACHE_SERVE_STALE` | Serve stale data on upstream error | `false` |
3535
| `MODBUS_READONLY` | Read-only mode: `false`, `true`, `deny` | `true` |
3636
| `MODBUS_TIMEOUT` | Upstream connection timeout | `10s` |
37+
| `MODBUS_REQUEST_DELAY` | Delay after each upstream request | `0` (disabled) |
3738
| `MODBUS_SHUTDOWN_TIMEOUT` | Graceful shutdown timeout | `30s` |
3839
| `LOG_LEVEL` | Log level: `INFO`, `DEBUG` | `INFO` |
3940

@@ -74,6 +75,7 @@ services:
7475
MODBUS_CACHE_SERVE_STALE: "false"
7576
MODBUS_READONLY: "true"
7677
MODBUS_TIMEOUT: "10s"
78+
MODBUS_REQUEST_DELAY: "0"
7779
MODBUS_SHUTDOWN_TIMEOUT: "30s"
7880
LOG_LEVEL: "INFO"
7981
restart: unless-stopped

SPEC.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Many Modbus devices (inverters, meters, battery systems) have limited polling ca
4242
- Support multiple slave IDs through single connection
4343
- Support clients requesting different slave IDs through the proxy
4444
- Auto-reconnect on connection failure (unlimited retries, no backoff)
45+
- Request pacing: configurable delay between upstream requests to prevent overwhelming slow devices
4546

4647
### 3. In-Memory Cache
4748

@@ -71,6 +72,13 @@ type CacheEntry struct {
7172
- Second request arriving while first is pending will wait for and share the first's response
7273
- Prevents thundering herd on cache miss
7374

75+
### Request Pacing
76+
- Configurable delay after each successful upstream request
77+
- Protects slow Modbus devices that cannot handle rapid-fire requests
78+
- Delay is context-aware: cancelled if the request context is cancelled
79+
- Only applied after successful requests (not during error recovery/reconnection)
80+
- Logged at DEBUG level when applied
81+
7482
### 4. Read-Only Mode
7583
Three modes:
7684
- `false`: Full read/write passthrough
@@ -93,6 +101,7 @@ Three modes:
93101
| `MODBUS_CACHE_SERVE_STALE` | Serve stale data on upstream error | `false` | `true`, `false` |
94102
| `MODBUS_READONLY` | Read-only mode | `true` | `false`, `true`, `deny` |
95103
| `MODBUS_TIMEOUT` | Upstream connection timeout | `10s` | `5s`, `30s` |
104+
| `MODBUS_REQUEST_DELAY` | Delay after each upstream request | `0` (disabled) | `100ms`, `500ms` |
96105
| `MODBUS_SHUTDOWN_TIMEOUT` | Graceful shutdown timeout | `30s` | `10s`, `60s` |
97106
| `LOG_LEVEL` | Log level | `INFO` | `INFO`, `DEBUG` |
98107

@@ -185,6 +194,7 @@ level=INFO msg="starting proxy" listen=:5502 upstream=192.168.1.100:502
185194
level=DEBUG msg="cache hit" slave_id=1 func=0x03 addr=0 qty=10
186195
level=DEBUG msg="cache miss" slave_id=1 func=0x03 addr=0 qty=10
187196
level=DEBUG msg="upstream read" slave_id=1 func=0x03 addr=0 qty=10 duration=15ms
197+
level=DEBUG msg="applying request delay" duration=100ms
188198
level=WARN msg="upstream error, serving stale" slave_id=1 error="timeout"
189199
level=INFO msg="shutting down"
190200
```

internal/config/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type Config struct {
2727
CacheServeStale bool
2828
ReadOnly ReadOnlyMode
2929
Timeout time.Duration
30+
RequestDelay time.Duration
3031
ShutdownTimeout time.Duration
3132
LogLevel string
3233
}
@@ -41,6 +42,7 @@ func Load() (*Config, error) {
4142
CacheServeStale: false,
4243
ReadOnly: ReadOnlyOn,
4344
Timeout: 10 * time.Second,
45+
RequestDelay: 0,
4446
ShutdownTimeout: 30 * time.Second,
4547
LogLevel: getEnv("LOG_LEVEL", "INFO"),
4648
}
@@ -95,6 +97,15 @@ func Load() (*Config, error) {
9597
cfg.Timeout = d
9698
}
9799

100+
// Parse request delay
101+
if s := os.Getenv("MODBUS_REQUEST_DELAY"); s != "" {
102+
d, err := time.ParseDuration(s)
103+
if err != nil {
104+
return nil, fmt.Errorf("invalid MODBUS_REQUEST_DELAY: %w", err)
105+
}
106+
cfg.RequestDelay = d
107+
}
108+
98109
// Parse shutdown timeout
99110
if s := os.Getenv("MODBUS_SHUTDOWN_TIMEOUT"); s != "" {
100111
d, err := time.ParseDuration(s)

internal/config/config_test.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ func TestLoad_Defaults(t *testing.T) {
4747
if cfg.Timeout != 10*time.Second {
4848
t.Errorf("expected 10s timeout, got %v", cfg.Timeout)
4949
}
50+
if cfg.RequestDelay != 0 {
51+
t.Errorf("expected 0 request delay, got %v", cfg.RequestDelay)
52+
}
5053
if cfg.ShutdownTimeout != 30*time.Second {
5154
t.Errorf("expected 30s shutdown timeout, got %v", cfg.ShutdownTimeout)
5255
}
@@ -72,6 +75,7 @@ func TestLoad_CustomValues(t *testing.T) {
7275
os.Setenv("MODBUS_CACHE_SERVE_STALE", "true")
7376
os.Setenv("MODBUS_READONLY", "false")
7477
os.Setenv("MODBUS_TIMEOUT", "5s")
78+
os.Setenv("MODBUS_REQUEST_DELAY", "100ms")
7579
os.Setenv("MODBUS_SHUTDOWN_TIMEOUT", "60s")
7680
os.Setenv("LOG_LEVEL", "DEBUG")
7781

@@ -83,6 +87,7 @@ func TestLoad_CustomValues(t *testing.T) {
8387
os.Unsetenv("MODBUS_CACHE_SERVE_STALE")
8488
os.Unsetenv("MODBUS_READONLY")
8589
os.Unsetenv("MODBUS_TIMEOUT")
90+
os.Unsetenv("MODBUS_REQUEST_DELAY")
8691
os.Unsetenv("MODBUS_SHUTDOWN_TIMEOUT")
8792
os.Unsetenv("LOG_LEVEL")
8893
}()
@@ -110,6 +115,9 @@ func TestLoad_CustomValues(t *testing.T) {
110115
if cfg.Timeout != 5*time.Second {
111116
t.Errorf("expected 5s timeout, got %v", cfg.Timeout)
112117
}
118+
if cfg.RequestDelay != 100*time.Millisecond {
119+
t.Errorf("expected 100ms request delay, got %v", cfg.RequestDelay)
120+
}
113121
if cfg.ShutdownTimeout != 60*time.Second {
114122
t.Errorf("expected 60s shutdown timeout, got %v", cfg.ShutdownTimeout)
115123
}
@@ -166,7 +174,7 @@ func TestLoad_InvalidDuration(t *testing.T) {
166174
os.Setenv("MODBUS_UPSTREAM", "localhost:502")
167175
defer os.Unsetenv("MODBUS_UPSTREAM")
168176

169-
tests := []string{"MODBUS_CACHE_TTL", "MODBUS_TIMEOUT", "MODBUS_SHUTDOWN_TIMEOUT"}
177+
tests := []string{"MODBUS_CACHE_TTL", "MODBUS_TIMEOUT", "MODBUS_REQUEST_DELAY", "MODBUS_SHUTDOWN_TIMEOUT"}
170178
for _, envVar := range tests {
171179
os.Setenv(envVar, "invalid")
172180
_, err := Load()

internal/modbus/client.go

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,23 @@ import (
1313

1414
// Client wraps a Modbus TCP client with auto-reconnect capability.
1515
type Client struct {
16-
address string
17-
timeout time.Duration
18-
logger *slog.Logger
16+
address string
17+
timeout time.Duration
18+
requestDelay time.Duration
19+
logger *slog.Logger
1920

2021
mu sync.Mutex
2122
client modbus.Client
2223
conn *modbus.TCPClientHandler
2324
}
2425

2526
// NewClient creates a new Modbus TCP client.
26-
func NewClient(address string, timeout time.Duration, logger *slog.Logger) *Client {
27+
func NewClient(address string, timeout, requestDelay time.Duration, logger *slog.Logger) *Client {
2728
return &Client{
28-
address: address,
29-
timeout: timeout,
30-
logger: logger,
29+
address: address,
30+
timeout: timeout,
31+
requestDelay: requestDelay,
32+
logger: logger,
3133
}
3234
}
3335

@@ -102,6 +104,16 @@ func (c *Client) Execute(ctx context.Context, req *Request) ([]byte, error) {
102104
}
103105
}
104106

107+
// Apply request delay if configured (only after successful requests)
108+
if c.requestDelay > 0 {
109+
c.logger.Debug("applying request delay", "duration", c.requestDelay)
110+
select {
111+
case <-time.After(c.requestDelay):
112+
case <-ctx.Done():
113+
// Context cancelled during delay - still return the successful result
114+
}
115+
}
116+
105117
return resp, nil
106118
}
107119

internal/proxy/proxy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func New(cfg *config.Config, logger *slog.Logger) (*Proxy, error) {
2727
p := &Proxy{
2828
cfg: cfg,
2929
logger: logger,
30-
client: modbus.NewClient(cfg.Upstream, cfg.Timeout, logger),
30+
client: modbus.NewClient(cfg.Upstream, cfg.Timeout, cfg.RequestDelay, logger),
3131
cache: cache.New(cfg.CacheTTL),
3232
}
3333

0 commit comments

Comments
 (0)