修正邮件外域多地址连接预算并保留失败原因
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-10-02 14:15:18 +08:00
parent 3389f188f3
commit eba1b9d101
2 changed files with 82 additions and 8 deletions

View File

@ -2,11 +2,70 @@ package smtp
import ( import (
"context" "context"
"errors"
"net" "net"
"net/netip"
"testing" "testing"
"time" "time"
) )
func TestDialResolvedAddressesFallsBackAfterTimeout(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
addresses := []netip.Addr{netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("2001:db8::1")}
calls := 0
conn, err := dialResolvedAddresses(context.Background(), addresses, "25", 10*time.Millisecond, func(ctx context.Context, network, address string) (net.Conn, error) {
calls++
if network != "tcp" || address != net.JoinHostPort(addresses[calls-1].String(), "25") {
t.Fatalf("unexpected dial target: %s %s", network, address)
}
if calls == 1 {
<-ctx.Done()
return nil, ctx.Err()
}
if ctx.Err() != nil {
t.Fatal("fallback inherited exhausted attempt context")
}
return client, nil
})
if err != nil || conn != client || calls != 2 {
t.Fatalf("fallback failed: conn=%v calls=%d err=%v", conn, calls, err)
}
}
func TestDialResolvedAddressesHonorsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
addresses := []netip.Addr{netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("192.0.2.2")}
calls := 0
_, err := dialResolvedAddresses(ctx, addresses, "25", time.Second, func(context.Context, string, string) (net.Conn, error) {
calls++
cancel()
return nil, context.Canceled
})
if calls != 1 || !errors.Is(err, context.Canceled) {
t.Fatalf("canceled parent was ignored: calls=%d err=%v", calls, err)
}
}
func TestDialResolvedAddressesRetainsFailures(t *testing.T) {
first := errors.New("first address failed")
second := errors.New("second address failed")
addresses := []netip.Addr{netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("192.0.2.2")}
calls := 0
_, err := dialResolvedAddresses(context.Background(), addresses, "25", time.Second, func(context.Context, string, string) (net.Conn, error) {
calls++
if calls == 1 {
return nil, first
}
return nil, second
})
if calls != 2 || !errors.Is(err, first) || !errors.Is(err, second) {
t.Fatalf("connection failures were lost: calls=%d err=%v", calls, err)
}
}
func TestSMTPContextCancelsGreeting(t *testing.T) { func TestSMTPContextCancelsGreeting(t *testing.T) {
client, server := net.Pipe() client, server := net.Pipe()
defer server.Close() defer server.Close()

View File

@ -62,7 +62,8 @@ func IsDeliveryUncertain(err error) bool {
} }
const ( const (
outboundSMTPDialTimeout = 2 * time.Second outboundSMTPDialTimeout = 10 * time.Second
outboundSMTPConnectTimeout = 30 * time.Second
outboundSMTPGreetingTimeout = 5 * time.Second outboundSMTPGreetingTimeout = 5 * time.Second
outboundSMTPTLSHandshakeTimeout = 2 * time.Second outboundSMTPTLSHandshakeTimeout = 2 * time.Second
outboundSMTPTransactionTimeout = 2 * time.Minute outboundSMTPTransactionTimeout = 2 * time.Minute
@ -215,9 +216,11 @@ func dialTCPContext(parent context.Context, addr string, publicOnly bool) (net.C
return nil, errors.New("smtp: invalid remote address") return nil, errors.New("smtp: invalid remote address")
} }
ctx, cancel := context.WithTimeout(parent, outboundSMTPDialTimeout) ctx, cancel := context.WithTimeout(parent, outboundSMTPConnectTimeout)
defer cancel() defer cancel()
addresses, err := resolveRemoteAddresses(ctx, host) resolveCtx, resolveCancel := context.WithTimeout(ctx, outboundSMTPDialTimeout)
addresses, err := resolveRemoteAddresses(resolveCtx, host)
resolveCancel()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -228,18 +231,30 @@ func dialTCPContext(parent context.Context, addr string, publicOnly bool) (net.C
} }
dialer := &net.Dialer{Timeout: outboundSMTPDialTimeout, KeepAlive: 30 * time.Second} dialer := &net.Dialer{Timeout: outboundSMTPDialTimeout, KeepAlive: 30 * time.Second}
var lastErr error return dialResolvedAddresses(ctx, addresses, port, outboundSMTPDialTimeout, dialer.DialContext)
}
// 为各地址分配独立连接预算,同时遵守父级总时限,避免首个地址耗尽全部回退时间。
func dialResolvedAddresses(ctx context.Context, addresses []netip.Addr, port string, timeout time.Duration, dial func(context.Context, string, string) (net.Conn, error)) (net.Conn, error) {
var failures []error
for _, address := range addresses { for _, address := range addresses {
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(address.String(), port)) if err := ctx.Err(); err != nil {
failures = append(failures, err)
break
}
attemptCtx, cancel := context.WithTimeout(ctx, timeout)
conn, err := dial(attemptCtx, "tcp", net.JoinHostPort(address.String(), port))
cancel()
if err == nil { if err == nil {
return conn, nil return conn, nil
} }
lastErr = err failures = append(failures, err)
} }
if lastErr == nil { if len(failures) == 0 {
return nil, errors.New("smtp: remote address is unavailable") return nil, errors.New("smtp: remote address is unavailable")
} }
return nil, fmt.Errorf("smtp: connect to remote address: %w", lastErr) // 保留各地址的失败原因,不能把最后一个未成功连接的地址当成唯一故障。
return nil, fmt.Errorf("smtp: connect to remote address: %w", errors.Join(failures...))
} }
func resolveRemoteAddresses(ctx context.Context, host string) ([]netip.Addr, error) { func resolveRemoteAddresses(ctx context.Context, host string) ([]netip.Addr, error) {