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>
136 lines
4.2 KiB
Go
136 lines
4.2 KiB
Go
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)
|
|
}
|