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) }