acd5291604
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>
91 lines
3.8 KiB
Go
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))
|
|
})
|
|
}
|