Files
drover-go/internal/checker/retry_test.go
T
root acd5291604
Build / test (push) Failing after 38s
Build / build-windows (push) Has been skipped
internal/checker: error classification + RU hints + tests
Adds:
- retry.go: classifyError() splits errors into Permanent vs Transient
  (used to gate auto-retry); isContextErr() detects ctx cancellation
  through wrapping (OpError, errors.Join).
- hints.go: hintFor(testID, err) returns short Russian explanation per
  failure step, with dedicated branches for SOCKS5 sentinels, every
  documented REP code (0x01..0x08), STUN sentinels, timeouts, and a
  friendly-name fallback.

Coverage: retry.go 100%, hints.go 100%; package total 94.2%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:58:56 +03:00

91 lines
3.8 KiB
Go

package checker
import (
"context"
"errors"
"io"
"net"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
)
// timeoutOnlyError is a minimal net.Error that reports Timeout()=true.
// Used to drive the net.Error.Timeout() branch in classifyError.
type timeoutOnlyError struct{}
func (timeoutOnlyError) Error() string { return "i/o timeout" }
func (timeoutOnlyError) Timeout() bool { return true }
func (timeoutOnlyError) Temporary() bool { return true }
func TestClassifyError(t *testing.T) {
cases := []struct {
name string
err error
want Classification
}{
{"nil", nil, ClassificationPermanent},
{"context_canceled", context.Canceled, ClassificationPermanent},
{"context_deadline", context.DeadlineExceeded, ClassificationPermanent},
{"econnrefused", syscall.ECONNREFUSED, ClassificationPermanent},
{"econnreset", syscall.ECONNRESET, ClassificationTransient},
{"econnreset_wrapped", &net.OpError{Op: "read", Err: syscall.ECONNRESET}, ClassificationTransient},
{"net_timeout", &timeoutOnlyError{}, ClassificationTransient},
{"dns_temporary", &net.DNSError{IsTemporary: true}, ClassificationTransient},
{"dns_timeout", &net.DNSError{IsTimeout: true}, ClassificationTransient},
{"dns_permanent", &net.DNSError{IsNotFound: true}, ClassificationPermanent},
{"socks5_auth_rejected", ErrAuthRejected, ClassificationPermanent},
{"socks5_bad_version", ErrSocks5BadVersion, ClassificationPermanent},
{"socks5_rejected_all_auth", ErrSocks5RejectedAllAuth, ClassificationPermanent},
{"socks5_credential_too_long", ErrCredentialTooLong, ClassificationPermanent},
{"socks5_host_too_long", ErrHostTooLong, ClassificationPermanent},
{"socks5_unsupported_relay_atyp", ErrUnsupportedRelayATYP, ClassificationPermanent},
{"socks5_short_reply", ErrShortReply, ClassificationPermanent},
{"socks5_reply_general_failure", ErrSocks5Reply{Code: 0x01}, ClassificationPermanent},
{"socks5_reply_not_allowed", ErrSocks5Reply{Code: 0x02}, ClassificationPermanent},
{"socks5_reply_refused", ErrSocks5Reply{Code: 0x05}, ClassificationPermanent},
{"socks5_reply_unsupported", ErrSocks5Reply{Code: 0x07}, ClassificationPermanent},
{"stun_too_short", ErrSTUNTooShort, ClassificationPermanent},
{"stun_bad_magic", ErrSTUNBadMagicCookie, ClassificationPermanent},
{"stun_not_success", ErrSTUNNotSuccess, ClassificationPermanent},
{"stun_txid_mismatch", ErrSTUNTxIDMismatch, ClassificationPermanent},
{"stun_no_mapped", ErrSTUNNoMappedAddress, ClassificationPermanent},
{"stun_unsupported_family", ErrSTUNUnsupportedFamily, ClassificationPermanent},
{"unexpected_eof", io.ErrUnexpectedEOF, ClassificationPermanent},
{"unexpected_eof_in_op", &net.OpError{Op: "read", Err: io.ErrUnexpectedEOF}, ClassificationTransient},
{"joined_canceled_with_timeout", errors.Join(context.Canceled, &timeoutOnlyError{}), ClassificationPermanent},
{"joined_canceled_with_econnreset", errors.Join(context.Canceled, syscall.ECONNRESET), ClassificationPermanent},
{"random", errors.New("ouch"), ClassificationPermanent},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := classifyError(c.err)
assert.Equal(t, c.want, got, "err=%v", c.err)
})
}
}
func TestIsContextErr(t *testing.T) {
t.Run("nil", func(t *testing.T) {
assert.False(t, isContextErr(nil))
})
t.Run("canceled", func(t *testing.T) {
assert.True(t, isContextErr(context.Canceled))
})
t.Run("deadline", func(t *testing.T) {
assert.True(t, isContextErr(context.DeadlineExceeded))
})
t.Run("joined_canceled_with_econnreset", func(t *testing.T) {
assert.True(t, isContextErr(errors.Join(context.Canceled, syscall.ECONNRESET)))
})
t.Run("random", func(t *testing.T) {
assert.False(t, isContextErr(errors.New("nope")))
})
t.Run("oprrror_wrapping_deadline", func(t *testing.T) {
err := &net.OpError{Op: "read", Err: context.DeadlineExceeded}
assert.True(t, isContextErr(err))
})
}