Files
drover-go/internal/checker/retry.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

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