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>
This commit is contained in:
@@ -0,0 +1,189 @@
|
|||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// socks5ReplyHints maps SOCKS5 REP codes to short Russian explanations
|
||||||
|
// used by hintFor for the "connect" and "udp" steps. Codes outside this
|
||||||
|
// table fall back to a generic "unknown REP" message.
|
||||||
|
var socks5ReplyHints = map[byte]string{
|
||||||
|
0x01: "общий сбой SOCKS5-сервера",
|
||||||
|
0x02: "правила прокси запрещают это соединение",
|
||||||
|
0x03: "сеть назначения недоступна",
|
||||||
|
0x04: "хост назначения недоступен",
|
||||||
|
0x05: "connection refused",
|
||||||
|
0x06: "истёк TTL",
|
||||||
|
0x07: "команда не поддерживается",
|
||||||
|
0x08: "тип адреса не поддерживается",
|
||||||
|
}
|
||||||
|
|
||||||
|
// tcpFriendlyName turns a testID into a Russian-friendly label for the
|
||||||
|
// generic fallback hint.
|
||||||
|
func tcpFriendlyName(testID string) string {
|
||||||
|
switch testID {
|
||||||
|
case "tcp":
|
||||||
|
return "TCP"
|
||||||
|
case "greet":
|
||||||
|
return "приветствие SOCKS5"
|
||||||
|
case "auth":
|
||||||
|
return "авторизация SOCKS5"
|
||||||
|
case "connect":
|
||||||
|
return "TCP-туннель к Discord"
|
||||||
|
case "udp":
|
||||||
|
return "UDP ASSOCIATE"
|
||||||
|
case "stun":
|
||||||
|
return "STUN round-trip"
|
||||||
|
case "api":
|
||||||
|
return "Discord API"
|
||||||
|
default:
|
||||||
|
return testID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hintFor returns a short Russian-language explanation of why a test
|
||||||
|
// failed. Returns "" when err is nil.
|
||||||
|
func hintFor(testID string, err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if isContextErr(err) {
|
||||||
|
return "Проверка отменена."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common error shapes we recognise across all testIDs.
|
||||||
|
var ne net.Error
|
||||||
|
isTimeout := errors.As(err, &ne) && ne.Timeout()
|
||||||
|
|
||||||
|
var rep ErrSocks5Reply
|
||||||
|
hasReply := errors.As(err, &rep)
|
||||||
|
|
||||||
|
switch testID {
|
||||||
|
case "tcp":
|
||||||
|
switch {
|
||||||
|
case isTimeout:
|
||||||
|
return "Превышен таймаут подключения — прокси может быть выключен или брандмауэр режет порт."
|
||||||
|
case errors.Is(err, syscall.ECONNREFUSED):
|
||||||
|
return "Прокси отклонил TCP-соединение — порт закрыт или сервис не запущен."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Прокси не отвечает по TCP — проверь host и port (%s).", err.Error())
|
||||||
|
|
||||||
|
case "greet":
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrSocks5BadVersion):
|
||||||
|
return "Сервер вернул не SOCKS5 — возможно, это HTTP-прокси."
|
||||||
|
case errors.Is(err, ErrSocks5RejectedAllAuth):
|
||||||
|
return "Прокси требует авторизацию, но мы её не предложили (или прокси не принимает наши методы)."
|
||||||
|
case errors.Is(err, ErrShortReply):
|
||||||
|
return "SOCKS5-сервер прислал укороченный ответ на приветствие."
|
||||||
|
case isTimeout:
|
||||||
|
return "SOCKS5-сервер не ответил на приветствие вовремя."
|
||||||
|
}
|
||||||
|
return genericFallback(testID, err)
|
||||||
|
|
||||||
|
case "auth":
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrAuthRejected):
|
||||||
|
return "Логин или пароль неверны."
|
||||||
|
case errors.Is(err, ErrCredentialTooLong):
|
||||||
|
return "Логин или пароль длиннее 255 байт — SOCKS5 такого не позволяет."
|
||||||
|
case errors.Is(err, ErrShortReply):
|
||||||
|
return "SOCKS5-сервер прислал укороченный ответ на авторизацию."
|
||||||
|
case isTimeout:
|
||||||
|
return "SOCKS5-сервер не ответил на авторизацию вовремя."
|
||||||
|
}
|
||||||
|
return genericFallback(testID, err)
|
||||||
|
|
||||||
|
case "connect":
|
||||||
|
if hasReply {
|
||||||
|
return socks5ReplyHint("connect", rep.Code)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrHostTooLong):
|
||||||
|
return "Имя хоста длиннее 255 байт — SOCKS5 такого не позволяет."
|
||||||
|
case errors.Is(err, ErrShortReply):
|
||||||
|
return "SOCKS5-сервер прислал укороченный ответ на CONNECT."
|
||||||
|
case isTimeout:
|
||||||
|
return "SOCKS5-сервер не ответил на CONNECT вовремя."
|
||||||
|
}
|
||||||
|
return genericFallback(testID, err)
|
||||||
|
|
||||||
|
case "udp":
|
||||||
|
if hasReply {
|
||||||
|
return socks5ReplyHint("udp", rep.Code)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrUnsupportedRelayATYP):
|
||||||
|
return "Прокси выдал IPv6 relay для UDP — пока не поддерживается, голос работать не будет."
|
||||||
|
case errors.Is(err, ErrShortReply):
|
||||||
|
return "SOCKS5-сервер прислал укороченный ответ на UDP ASSOCIATE."
|
||||||
|
case isTimeout:
|
||||||
|
return "SOCKS5-сервер не ответил на UDP ASSOCIATE вовремя."
|
||||||
|
}
|
||||||
|
return genericFallback(testID, err)
|
||||||
|
|
||||||
|
case "stun":
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrSTUNNoMappedAddress):
|
||||||
|
return "STUN-ответ без XOR-MAPPED-ADDRESS — UDP-релей не пропускает обратный трафик."
|
||||||
|
case errors.Is(err, ErrSTUNTooShort):
|
||||||
|
return "STUN-ответ короче 20-байтного заголовка — релей возвращает мусор."
|
||||||
|
case errors.Is(err, ErrSTUNBadMagicCookie):
|
||||||
|
return "STUN-ответ без правильного magic cookie — релей возвращает мусор."
|
||||||
|
case errors.Is(err, ErrSTUNNotSuccess):
|
||||||
|
return "STUN-сервер вернул не Binding Success — UDP-релей сломан."
|
||||||
|
case errors.Is(err, ErrSTUNTxIDMismatch):
|
||||||
|
return "STUN-ответ с чужим transaction ID — релей путает пакеты."
|
||||||
|
case errors.Is(err, ErrSTUNUnsupportedFamily):
|
||||||
|
return "STUN-ответ с неподдерживаемым семейством адресов."
|
||||||
|
case isTimeout:
|
||||||
|
return "STUN-сервер не ответил вовремя — UDP-релей не работает в обе стороны."
|
||||||
|
}
|
||||||
|
return genericFallback(testID, err)
|
||||||
|
|
||||||
|
case "api":
|
||||||
|
switch {
|
||||||
|
case isTimeout:
|
||||||
|
return "Discord API не ответил вовремя через прокси — таймаут."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Discord API недоступен через прокси — TLS handshake упал (%s).", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return genericFallback(testID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// socks5ReplyHint formats a SOCKS5 REP-code hint specialised by step.
|
||||||
|
// "connect" wording references Discord; "udp" wording references voice.
|
||||||
|
func socks5ReplyHint(step string, code byte) string {
|
||||||
|
desc, ok := socks5ReplyHints[code]
|
||||||
|
if !ok {
|
||||||
|
desc = "неизвестная REP"
|
||||||
|
}
|
||||||
|
switch step {
|
||||||
|
case "udp":
|
||||||
|
// 0x07 (cmd not supported) is the headline UDP failure mode.
|
||||||
|
if code == 0x07 {
|
||||||
|
return "Прокси не поддерживает UDP ASSOCIATE — голос Discord работать не будет."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Прокси отклонил UDP ASSOCIATE (REP=%02X, %s).", code, desc)
|
||||||
|
case "connect":
|
||||||
|
if code == 0x05 {
|
||||||
|
return "Прокси не смог подключиться к Discord (REP=05, connection refused)."
|
||||||
|
}
|
||||||
|
if code == 0x07 {
|
||||||
|
return "Прокси не поддерживает CONNECT (REP=07)."
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Прокси отклонил CONNECT к Discord (REP=%02X, %s).", code, desc)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Прокси отклонил запрос (REP=%02X, %s).", code, desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// genericFallback is the catch-all used when we don't recognise the
|
||||||
|
// (testID, err) shape. Keeps the user informed without exposing raw Go
|
||||||
|
// error wrapping.
|
||||||
|
func genericFallback(testID string, err error) string {
|
||||||
|
return fmt.Sprintf("Не удалось выполнить шаг «%s»: %s", tcpFriendlyName(testID), err.Error())
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHintFor(t *testing.T) {
|
||||||
|
t.Run("nil_err_returns_empty", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "", hintFor("tcp", nil))
|
||||||
|
assert.Equal(t, "", hintFor("anything", nil))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("context_canceled_uniform", func(t *testing.T) {
|
||||||
|
// Cancellation is always reported as «Проверка отменена.» across
|
||||||
|
// all testIDs.
|
||||||
|
for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "stun", "api", "unknown"} {
|
||||||
|
assert.Equal(t, "Проверка отменена.", hintFor(id, context.Canceled), "id=%s", id)
|
||||||
|
assert.Equal(t, "Проверка отменена.", hintFor(id, context.DeadlineExceeded), "id=%s", id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
testID string
|
||||||
|
err error
|
||||||
|
substring string
|
||||||
|
}{
|
||||||
|
{"tcp_timeout", "tcp", &timeoutOnlyError{}, "таймаут"},
|
||||||
|
{"greet_bad_version_mentions_socks5", "greet", ErrSocks5BadVersion, "SOCKS5"},
|
||||||
|
{"greet_bad_version_mentions_negation", "greet", ErrSocks5BadVersion, "не"},
|
||||||
|
{"greet_rejected_all_auth_mentions_auth_or_methods", "greet", ErrSocks5RejectedAllAuth, "авторизаци"},
|
||||||
|
{"auth_login", "auth", ErrAuthRejected, "Логин"},
|
||||||
|
{"auth_password", "auth", ErrAuthRejected, "паро"},
|
||||||
|
{"connect_refused_rep05", "connect", ErrSocks5Reply{Code: 0x05}, "REP=05"},
|
||||||
|
{"connect_refused_text", "connect", ErrSocks5Reply{Code: 0x05}, "connection refused"},
|
||||||
|
{"connect_unsupported_rep07", "connect", ErrSocks5Reply{Code: 0x07}, "REP=07"},
|
||||||
|
{"udp_unsupported_mentions_udp", "udp", ErrSocks5Reply{Code: 0x07}, "UDP"},
|
||||||
|
{"udp_unsupported_mentions_unsupported", "udp", ErrSocks5Reply{Code: 0x07}, "не поддерж"},
|
||||||
|
{"udp_atyp_ipv6", "udp", ErrUnsupportedRelayATYP, "IPv6"},
|
||||||
|
{"stun_no_mapped_xor", "stun", ErrSTUNNoMappedAddress, "XOR-MAPPED"},
|
||||||
|
{"stun_timeout_mentions_stun", "stun", &timeoutOnlyError{}, "STUN"},
|
||||||
|
{"api_timeout_mentions_api_or_timeout", "api", &timeoutOnlyError{}, "таймаут"},
|
||||||
|
{"unknown_test_fallback_id", "unknown_test", errors.New("oops"), "unknown_test"},
|
||||||
|
{"unknown_test_fallback_err", "unknown_test", errors.New("oops"), "oops"},
|
||||||
|
{"tcp_fallback_friendly_name", "tcp", errors.New("weird"), "TCP"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
c := c
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := hintFor(c.testID, c.err)
|
||||||
|
assert.Contains(t, got, c.substring, "got=%q", got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHintFor_AllSocks5ReplyCodesCovered(t *testing.T) {
|
||||||
|
// Every documented REP code (0x01..0x08) should produce a non-empty
|
||||||
|
// hint when surfaced via "connect" or "udp".
|
||||||
|
for code := byte(0x01); code <= 0x08; code++ {
|
||||||
|
err := ErrSocks5Reply{Code: code}
|
||||||
|
assert.NotEmpty(t, hintFor("connect", err), "connect code=%02X", code)
|
||||||
|
assert.NotEmpty(t, hintFor("udp", err), "udp code=%02X", code)
|
||||||
|
}
|
||||||
|
// Unknown REP code (0xFE) still gets a sensible fallback rather than
|
||||||
|
// an empty string.
|
||||||
|
err := ErrSocks5Reply{Code: 0xFE}
|
||||||
|
assert.NotEmpty(t, hintFor("connect", err))
|
||||||
|
assert.NotEmpty(t, hintFor("udp", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHintFor_PerStepBranches(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
testID string
|
||||||
|
err error
|
||||||
|
substring string
|
||||||
|
}{
|
||||||
|
// tcp: ECONNREFUSED + generic fallback
|
||||||
|
{"tcp_econnrefused", "tcp", syscall.ECONNREFUSED, "отклонил"},
|
||||||
|
{"tcp_generic", "tcp", errors.New("dial fail"), "TCP"},
|
||||||
|
|
||||||
|
// greet: short reply, timeout, fallback
|
||||||
|
{"greet_short_reply", "greet", ErrShortReply, "укороченный"},
|
||||||
|
{"greet_timeout", "greet", &timeoutOnlyError{}, "вовремя"},
|
||||||
|
{"greet_fallback", "greet", errors.New("weird"), "приветствие"},
|
||||||
|
|
||||||
|
// auth: credential too long, short reply, timeout, fallback
|
||||||
|
{"auth_cred_too_long", "auth", ErrCredentialTooLong, "255"},
|
||||||
|
{"auth_short_reply", "auth", ErrShortReply, "укороченный"},
|
||||||
|
{"auth_timeout", "auth", &timeoutOnlyError{}, "вовремя"},
|
||||||
|
{"auth_fallback", "auth", errors.New("weird"), "авторизация"},
|
||||||
|
|
||||||
|
// connect: host too long, short reply, timeout, generic REP, fallback
|
||||||
|
{"connect_host_too_long", "connect", ErrHostTooLong, "255"},
|
||||||
|
{"connect_short_reply", "connect", ErrShortReply, "укороченный"},
|
||||||
|
{"connect_timeout", "connect", &timeoutOnlyError{}, "вовремя"},
|
||||||
|
{"connect_generic_rep", "connect", ErrSocks5Reply{Code: 0x03}, "REP=03"},
|
||||||
|
{"connect_unknown_rep", "connect", ErrSocks5Reply{Code: 0xFE}, "REP=FE"},
|
||||||
|
{"connect_fallback", "connect", errors.New("weird"), "TCP-туннель"},
|
||||||
|
|
||||||
|
// udp: short reply, timeout, fallback, non-7 REP
|
||||||
|
{"udp_short_reply", "udp", ErrShortReply, "укороченный"},
|
||||||
|
{"udp_timeout", "udp", &timeoutOnlyError{}, "вовремя"},
|
||||||
|
{"udp_other_rep", "udp", ErrSocks5Reply{Code: 0x05}, "REP=05"},
|
||||||
|
{"udp_unknown_rep", "udp", ErrSocks5Reply{Code: 0xFE}, "REP=FE"},
|
||||||
|
{"udp_fallback", "udp", errors.New("weird"), "UDP ASSOCIATE"},
|
||||||
|
|
||||||
|
// stun: every sentinel branch
|
||||||
|
{"stun_too_short", "stun", ErrSTUNTooShort, "20"},
|
||||||
|
{"stun_bad_magic", "stun", ErrSTUNBadMagicCookie, "magic"},
|
||||||
|
{"stun_not_success", "stun", ErrSTUNNotSuccess, "Binding"},
|
||||||
|
{"stun_txid_mismatch", "stun", ErrSTUNTxIDMismatch, "transaction"},
|
||||||
|
{"stun_unsupported_family", "stun", ErrSTUNUnsupportedFamily, "семейством"},
|
||||||
|
{"stun_fallback", "stun", errors.New("weird"), "STUN"},
|
||||||
|
|
||||||
|
// api: timeout vs generic
|
||||||
|
{"api_timeout", "api", &timeoutOnlyError{}, "таймаут"},
|
||||||
|
{"api_generic", "api", errors.New("tls boom"), "TLS"},
|
||||||
|
|
||||||
|
// socks5ReplyHint via uncategorised step (default branch)
|
||||||
|
// — we can't reach it via hintFor with current testIDs, but the
|
||||||
|
// default formatter still needs to be exercised.
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
c := c
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := hintFor(c.testID, c.err)
|
||||||
|
assert.Contains(t, got, c.substring, "got=%q", got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocks5ReplyHint_DefaultStep(t *testing.T) {
|
||||||
|
// socks5ReplyHint("", code) hits the final fallback formatter.
|
||||||
|
got := socks5ReplyHint("", 0x03)
|
||||||
|
assert.Contains(t, got, "REP=03")
|
||||||
|
got = socks5ReplyHint("", 0xFE)
|
||||||
|
assert.Contains(t, got, "REP=FE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTcpFriendlyName(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"tcp": "TCP",
|
||||||
|
"greet": "приветствие SOCKS5",
|
||||||
|
"auth": "авторизация SOCKS5",
|
||||||
|
"connect": "TCP-туннель к Discord",
|
||||||
|
"udp": "UDP ASSOCIATE",
|
||||||
|
"stun": "STUN round-trip",
|
||||||
|
"api": "Discord API",
|
||||||
|
"weirdo": "weirdo",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
t.Run(in, func(t *testing.T) {
|
||||||
|
assert.Equal(t, want, tcpFriendlyName(in))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Classification is the result of classifyError. Transient errors are
|
||||||
|
// candidates for one auto-retry (governed by Config.MaxRetries in
|
||||||
|
// checker.go). Permanent errors are reported to the user as-is.
|
||||||
|
type Classification int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ClassificationPermanent — caller should NOT retry. Either the user
|
||||||
|
// config is wrong (bad credentials, refused), the proxy is broken in
|
||||||
|
// a way retry won't fix (bad SOCKS5 version, malformed STUN reply),
|
||||||
|
// or the caller's context is already cancelled.
|
||||||
|
ClassificationPermanent Classification = iota
|
||||||
|
// ClassificationTransient — caller MAY retry once. Network blip,
|
||||||
|
// timeout, RST mid-handshake, DNS temporary failure.
|
||||||
|
ClassificationTransient
|
||||||
|
)
|
||||||
|
|
||||||
|
// classifyError decides whether err is worth retrying.
|
||||||
|
//
|
||||||
|
// Transient (retry):
|
||||||
|
// - net.Error.Timeout() == true
|
||||||
|
// - errors.Is(err, syscall.ECONNRESET)
|
||||||
|
// - net.DNSError.IsTemporary || .IsTimeout
|
||||||
|
// - io.ErrUnexpectedEOF wrapped inside a *net.OpError on a Read (proxy
|
||||||
|
// hung up mid-reply mid-flight; bare io.ErrUnexpectedEOF without an
|
||||||
|
// OpError wrapper means we got a malformed reply and should not retry)
|
||||||
|
//
|
||||||
|
// Permanent (don't retry):
|
||||||
|
// - context.Canceled / context.DeadlineExceeded
|
||||||
|
// - errors.Is(err, syscall.ECONNREFUSED)
|
||||||
|
// - any of our SOCKS5/STUN sentinels
|
||||||
|
// - everything else we don't explicitly classify
|
||||||
|
//
|
||||||
|
// Returns ClassificationPermanent on nil err (defensive).
|
||||||
|
func classifyError(err error) Classification {
|
||||||
|
if err == nil {
|
||||||
|
return ClassificationPermanent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context cancellation always wins — don't retry into a cancelled
|
||||||
|
// context, even if the chain also contains a timeout error.
|
||||||
|
if isContextErr(err) {
|
||||||
|
return ClassificationPermanent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanent: explicit refused.
|
||||||
|
if errors.Is(err, syscall.ECONNREFUSED) {
|
||||||
|
return ClassificationPermanent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanent: our SOCKS5 sentinels (auth refused, bad version,
|
||||||
|
// malformed credentials, etc.). Retrying won't fix any of these.
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrSocks5BadVersion),
|
||||||
|
errors.Is(err, ErrSocks5RejectedAllAuth),
|
||||||
|
errors.Is(err, ErrAuthRejected),
|
||||||
|
errors.Is(err, ErrCredentialTooLong),
|
||||||
|
errors.Is(err, ErrHostTooLong),
|
||||||
|
errors.Is(err, ErrUnsupportedRelayATYP),
|
||||||
|
errors.Is(err, ErrShortReply):
|
||||||
|
return ClassificationPermanent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanent: any non-zero SOCKS5 REP code. Includes 0x05 refused,
|
||||||
|
// 0x07 cmd unsupported, 0x02 not allowed by ruleset — none of which
|
||||||
|
// retry will fix.
|
||||||
|
var rep ErrSocks5Reply
|
||||||
|
if errors.As(err, &rep) {
|
||||||
|
return ClassificationPermanent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanent: STUN sentinels (malformed responses, missing attrs).
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrSTUNTooShort),
|
||||||
|
errors.Is(err, ErrSTUNBadMagicCookie),
|
||||||
|
errors.Is(err, ErrSTUNNotSuccess),
|
||||||
|
errors.Is(err, ErrSTUNTxIDMismatch),
|
||||||
|
errors.Is(err, ErrSTUNNoMappedAddress),
|
||||||
|
errors.Is(err, ErrSTUNUnsupportedFamily):
|
||||||
|
return ClassificationPermanent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient: ECONNRESET (peer hung up mid-stream).
|
||||||
|
if errors.Is(err, syscall.ECONNRESET) {
|
||||||
|
return ClassificationTransient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient: DNS temporary failure or DNS timeout.
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) {
|
||||||
|
if dnsErr.IsTemporary || dnsErr.IsTimeout {
|
||||||
|
return ClassificationTransient
|
||||||
|
}
|
||||||
|
return ClassificationPermanent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient: io.ErrUnexpectedEOF wrapped inside a net.OpError. Bare
|
||||||
|
// io.ErrUnexpectedEOF (synthesised by our SOCKS5 readers) is a
|
||||||
|
// malformed-reply signal and stays permanent.
|
||||||
|
var opErr *net.OpError
|
||||||
|
if errors.As(err, &opErr) {
|
||||||
|
if errors.Is(opErr.Err, io.ErrUnexpectedEOF) {
|
||||||
|
return ClassificationTransient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient: net.Error.Timeout(). Checked AFTER the typed sentinels
|
||||||
|
// so that a timeout-shaped error wrapping a permanent sentinel still
|
||||||
|
// classifies permanent.
|
||||||
|
var ne net.Error
|
||||||
|
if errors.As(err, &ne) && ne.Timeout() {
|
||||||
|
return ClassificationTransient
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClassificationPermanent
|
||||||
|
}
|
||||||
|
|
||||||
|
// isContextErr returns true when err's chain contains context.Canceled
|
||||||
|
// or context.DeadlineExceeded. Used by checker.go to label cancelled
|
||||||
|
// tests as Error="cancelled".
|
||||||
|
func isContextErr(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user