Skip to content
Open
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
15 changes: 15 additions & 0 deletions ascii_over_tcp_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,27 @@ type ASCIIOverTCPClientHandler struct {
}

// NewASCIIOverTCPClientHandler allocates and initializes a ASCIIOverTCPClientHandler.
// The handler uses exponential backoff (10ms-5s) with 30s timeout by default for link recovery.
// This is appropriate for TCP network links. For custom backoff, set LinkRecoveryBackoff explicitly.
func NewASCIIOverTCPClientHandler(address string) *ASCIIOverTCPClientHandler {
handler := &ASCIIOverTCPClientHandler{}
handler.Address = address
handler.Timeout = tcpTimeout
handler.IdleTimeout = tcpIdleTimeout
handler.Dial = defaultDialFunc(handler.Timeout)
// Default exponential backoff for TCP: 10ms initial, suitable for faster network recovery
handler.LinkRecoveryBackoff = NewExponentialBackoff(
10*time.Millisecond, // Initial interval (network-appropriate)
5*time.Second, // Max interval
30*time.Second, // Timeout
)
// Default protocol recovery for transaction ID mismatches: 10ms with 100ms timeout
// Protocol recovery is just processing junk data, fail fast if it persists
handler.ProtocolRecoveryBackoff = NewExponentialBackoff(
10*time.Millisecond, // Initial interval
50*time.Millisecond, // Max interval
100*time.Millisecond, // Timeout (fail fast for junk data)
)
return handler
}

Expand Down
16 changes: 8 additions & 8 deletions ascii_transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,10 @@ func TestASCIISerialTransporter_RecoveryDisabledOnReadEOF(t *testing.T) {

_, err := transporter.Send(context.Background(), reqASCII)
if err == nil {
t.Fatal("expected link recovery timeout error, got nil")
t.Fatal("expected link recovery error, got nil")
}
if !strings.Contains(err.Error(), "link recovery timeout reached") || !errors.Is(err, io.EOF) {
t.Fatalf("expected link recovery timeout wrapping EOF, got %v", err)
if !strings.Contains(err.Error(), "no link recovery configured") || !errors.Is(err, io.EOF) {
t.Fatalf("expected no link recovery configured wrapping EOF, got %v", err)
}
if got := port.written.Bytes(); !bytes.Equal(got, reqASCII) {
t.Fatalf("expected request %q, got %q", reqASCII, got)
Expand All @@ -225,10 +225,10 @@ func TestASCIISerialTransporter_ReconnectBudgetExhaustedOnReadEOF(t *testing.T)
_, err := transporter.Send(context.Background(), reqASCII)
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected link recovery timeout error, got nil")
t.Fatal("expected link recovery exhausted error, got nil")
}
if !strings.Contains(err.Error(), "link recovery timeout reached") {
t.Fatalf("expected link recovery timeout error, got %v", err)
if !strings.Contains(err.Error(), "link recovery exhausted") {
t.Fatalf("expected link recovery exhausted error, got %v", err)
}
if !strings.Contains(err.Error(), "could not open") {
t.Fatalf("expected reconnect open failure to be wrapped, got %v", err)
Expand Down Expand Up @@ -262,8 +262,8 @@ func TestASCIISerialTransporter_ReconnectOnWriteEOF(t *testing.T) {
if err == nil {
t.Fatal("expected reconnect error after write EOF, got nil")
}
if !strings.Contains(err.Error(), "link recovery timeout reached") || !strings.Contains(err.Error(), "could not open") {
t.Fatalf("expected timed-out reconnect open failure, got %v", err)
if !strings.Contains(err.Error(), "link recovery exhausted") || !strings.Contains(err.Error(), "could not open") {
t.Fatalf("expected link recovery exhausted with reconnect failure, got %v", err)
}
if elapsed < recoveryTimeout-20*time.Millisecond {
t.Fatalf("expected recovery to keep retrying for about %v, returned after %v", recoveryTimeout, elapsed)
Expand Down
14 changes: 9 additions & 5 deletions asciiclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,18 @@ type ASCIIClientHandler struct {
}

// NewASCIIClientHandler allocates and initializes a ASCIIClientHandler.
// The handler uses exponential backoff (100ms-5s) with 30s timeout by default for link recovery.
func NewASCIIClientHandler(address string) *ASCIIClientHandler {
handler := &ASCIIClientHandler{}
handler.Address = address
handler.Timeout = serialTimeout
handler.IdleTimeout = serialIdleTimeout
handler.ReconnectRetryInterval = serialReconnectRetryInterval
// Default exponential backoff for RS485: 100ms→2s with 30s timeout
handler.LinkRecoveryBackoff = NewExponentialBackoff(
100*time.Millisecond, // Initial interval (RS485-appropriate)
5*time.Second, // Max interval cap
30*time.Second, // Timeout
)
return handler
}

Expand Down Expand Up @@ -183,14 +189,12 @@ func (mb *asciiSerialTransporter) Send(ctx context.Context, aduRequest []byte) (
mb.lastActivity = time.Now()
mb.startCloseTimer()

linkRecoveryDeadline := time.Now().Add(mb.LinkRecoveryTimeout)

for {
// Send the request
mb.logf("modbus: send % x\n", aduRequest)
if _, err = mb.port.Write(aduRequest); err != nil {
if mb.shouldRecover(err) {
if err = mb.reconnect(ctx, err, linkRecoveryDeadline); err != nil {
if err = mb.reconnect(ctx, err); err != nil {
return
}
continue
Expand All @@ -206,7 +210,7 @@ func (mb *asciiSerialTransporter) Send(ctx context.Context, aduRequest []byte) (
}
if err != nil {
if mb.shouldRecover(err) {
if err = mb.reconnect(ctx, err, linkRecoveryDeadline); err != nil {
if err = mb.reconnect(ctx, err); err != nil {
return
}
continue
Expand Down
203 changes: 203 additions & 0 deletions backoff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package modbus

import (
"math"
"time"
)

// BackoffType defines the retry interval growth algorithm
type BackoffType int

const (
BackoffFixed BackoffType = iota // Constant interval
BackoffLinear // Linear growth
BackoffExponential // Exponential growth
)

// BackoffStrategy defines a unified retry strategy for connection recovery.
// It controls retry intervals, total timeout budget, and maximum attempt count.
//
// Example usage with exponential backoff and timeout:
//
// handler := modbus.NewRTUClientHandler("/dev/ttyUSB0")
// handler.LinkRecoveryBackoff = &modbus.BackoffStrategy{
// Type: modbus.BackoffExponential,
// InitialInterval: 10 * time.Millisecond,
// MaxInterval: 5 * time.Second,
// Multiplier: 2.0,
// Timeout: 30 * time.Second,
// MaxAttempts: 0, // unlimited attempts within timeout
// }
// // Retry progression: 10ms → 20ms → 40ms → 80ms → ... → 5s (capped)
//
// Example with max attempts:
//
// handler.LinkRecoveryBackoff = &modbus.BackoffStrategy{
// Type: modbus.BackoffFixed,
// InitialInterval: 100 * time.Millisecond,
// MaxInterval: 100 * time.Millisecond,
// Timeout: 0, // unlimited time
// MaxAttempts: 10,
// }
type BackoffStrategy struct {
// Type specifies the interval growth algorithm
Type BackoffType
// InitialInterval is the starting delay for the first retry attempt
InitialInterval time.Duration
// MaxInterval caps the maximum delay to prevent unbounded growth (0 = unlimited)
MaxInterval time.Duration
// Multiplier is the growth factor for exponential/linear backoff
// - Exponential: interval = InitialInterval * (Multiplier ^ attempt)
// - Linear: interval = InitialInterval * (1 + attempt * Multiplier)
// - Fixed: Multiplier is ignored
Multiplier float64

// Timeout is the total time budget for all retry attempts (0 = unlimited)
Timeout time.Duration
// MaxAttempts is the maximum number of retry attempts (0 = unlimited)
MaxAttempts int
}

// Next calculates the backoff interval for the given attempt number.
// attempt is zero-indexed (0 = first retry, 1 = second retry, etc.)
// The returned duration is capped at MaxInterval if MaxInterval > 0,
// otherwise growth is unlimited.
func (b *BackoffStrategy) Next(attempt int) time.Duration {
if b == nil {
return 0
}

var interval time.Duration

switch b.Type {
case BackoffFixed:
interval = b.InitialInterval

case BackoffLinear:
// Linear: InitialInterval * (1 + attempt * Multiplier)
multiplier := b.Multiplier
if multiplier <= 0 {
multiplier = 1.0 // Default linear growth
}
factor := 1.0 + float64(attempt)*multiplier
interval = time.Duration(float64(b.InitialInterval) * factor)

case BackoffExponential:
// Exponential: InitialInterval * (Multiplier ^ attempt)
multiplier := b.Multiplier
if multiplier <= 1.0 {
multiplier = 2.0 // Default exponential growth
}
factor := math.Pow(multiplier, float64(attempt))
interval = time.Duration(float64(b.InitialInterval) * factor)

default:
// Default to fixed if type is unrecognized
interval = b.InitialInterval
}

// Cap at MaxInterval
if b.MaxInterval > 0 && interval > b.MaxInterval {
interval = b.MaxInterval
}

return interval
}

// ShouldRetry determines whether another retry attempt should be made.
// It checks both the timeout and max attempts conditions.
// Returns false if either limit has been reached, true otherwise.
func (b *BackoffStrategy) ShouldRetry(attempt int, elapsed time.Duration) bool {
if b == nil {
return false
}

// Check timeout limit
if b.Timeout > 0 && elapsed >= b.Timeout {
return false
}

// Check max attempts limit
if b.MaxAttempts > 0 && attempt >= b.MaxAttempts {
return false
}

return true
}

// Clone creates an independent copy of the BackoffStrategy.
func (b *BackoffStrategy) Clone() *BackoffStrategy {
if b == nil {
return nil
}
return &BackoffStrategy{
Type: b.Type,
InitialInterval: b.InitialInterval,
MaxInterval: b.MaxInterval,
Multiplier: b.Multiplier,
Timeout: b.Timeout,
MaxAttempts: b.MaxAttempts,
}
}

// NewExponentialBackoff returns an exponential backoff strategy with custom intervals and timeout.
// Progression: initial → initial*2 → initial*4 → ... → max (capped)
func NewExponentialBackoff(initialInterval, maxInterval, timeout time.Duration) *BackoffStrategy {
return &BackoffStrategy{
Type: BackoffExponential,
InitialInterval: initialInterval,
MaxInterval: maxInterval,
Multiplier: 2.0,
Timeout: timeout,
MaxAttempts: 0,
}
}

// NewLinearBackoff returns a linear backoff strategy with custom intervals and timeout.
// Progression: initial → initial*2 → initial*3 → ... → max (capped)
func NewLinearBackoff(initialInterval, maxInterval, timeout time.Duration) *BackoffStrategy {
return &BackoffStrategy{
Type: BackoffLinear,
InitialInterval: initialInterval,
MaxInterval: maxInterval,
Multiplier: 1.0,
Timeout: timeout,
MaxAttempts: 0,
}
}

// NewFixedBackoff returns a backoff strategy with a constant retry interval.
func NewFixedBackoff(interval, timeout time.Duration) *BackoffStrategy {
return &BackoffStrategy{
Type: BackoffFixed,
InitialInterval: interval,
MaxInterval: interval,
Multiplier: 0,
Timeout: timeout,
MaxAttempts: 0,
}
}

// NewLinearBackoffWithMaxAttempts returns a linear backoff strategy with max attempt limit instead of timeout.
func NewLinearBackoffWithMaxAttempts(initialInterval, maxInterval time.Duration, maxAttempts int) *BackoffStrategy {
return &BackoffStrategy{
Type: BackoffLinear,
InitialInterval: initialInterval,
MaxInterval: maxInterval,
Multiplier: 1.0,
Timeout: 0,
MaxAttempts: maxAttempts,
}
}

// NewExponentialBackoffWithMaxAttempts returns an exponential backoff strategy with max attempt limit instead of timeout.
func NewExponentialBackoffWithMaxAttempts(initialInterval, maxInterval time.Duration, maxAttempts int) *BackoffStrategy {
return &BackoffStrategy{
Type: BackoffExponential,
InitialInterval: initialInterval,
MaxInterval: maxInterval,
Multiplier: 2.0,
Timeout: 0,
MaxAttempts: maxAttempts,
}
}
Loading