Skip to main content

Error Handling

Errors come in two flavors and they're not the same thing:

  1. Network / protocol errors. DNS failed, connection refused, TLS handshake blew up, or the request timed out before a response. Either it never made it, or it made it and the server never replied. These come back as a Go error, a Python exception, a Node thrown error, or a .NET exception.

  2. Real responses with a non-2xx status. The server got the request, processed it, didn't like it, and sent back 404 or 500 or similar. The HTTP exchange completed. These come back as normal Response objects, and the caller checks StatusCode.

Mixing them up is the most common bug in this space. A 500 isn't a network error. The server told you no, and the connection is fine.

The split, with code

resp, err := s.Get(ctx, url)
if err != nil {
// Network / protocol / context error. No response.
return err
}
defer resp.Close()

if resp.StatusCode >= 500 {
// Server-side error. The exchange completed.
return fmt.Errorf("server error: %d", resp.StatusCode)
}
if resp.StatusCode >= 400 {
// Client-side error. You sent something the server rejected.
return fmt.Errorf("client error: %d", resp.StatusCode)
}

// 2xx. We're good.
body, _ := resp.Bytes()

Common error shapes

These come back as a real error, not a Response:

DNS failure

dns_resolve nope.example: lookup nope.example: no such host

In Go, this wraps a *net.DNSError. Check IsNotFound, IsTemporary, IsTimeout on it.

var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
if dnsErr.IsNotFound { /* domain doesn't exist */ }
if dnsErr.IsTimeout { /* DNS server didn't reply in time */ }
}

In other bindings the message string contains dns_resolve or lookup.

Connection refused

dial example.com: dial tcp 1.2.3.4:443: connect: connection refused

Server isn't listening, or a firewall is dropping it. Same shape across all bindings.

TLS handshake failure

tls: handshake failure
remote error: tls: protocol_version

Could be a cert mismatch, an expired cert, a server that only speaks TLS 1.3 with your config disabling it, or an anti-bot system rejecting your fingerprint at TLS level. The message usually carries a hint, though they're not always easy to read.

Timeout

context deadline exceeded
i/o timeout

In Go, errors.Is(err, context.DeadlineExceeded) returns true when the request didn't finish before your context deadline. There's also a wrapped *net.OpError with Timeout() == true for raw socket timeouts.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

_, err := s.Get(ctx, "https://httpbin.org/delay/10")
if errors.Is(err, context.DeadlineExceeded) {
// we hit our timeout
}

Cancellation

context canceled

Same as timeout but voluntary. errors.Is(err, context.Canceled).

What's a real response (not an error)

These come back as a populated Response with a status code, not as an error:

  • 4xx: Bad Request, Unauthorized, Forbidden, Not Found, Method Not Allowed, the usual suspects.
  • 5xx: Server errors, Bad Gateway, Service Unavailable, Gateway Timeout.
  • 3xx redirects (when the lib stops following them, e.g. with WithoutRedirects()).
  • Empty bodies, unexpected Content-Types, malformed JSON in the body.

The HTTP exchange completed and the server replied. Whether the caller treats it as a failure is business logic, not a transport concern.

Typed errors (Go)

The transport package wraps every network-side error in a transport.TransportError carrying enough context to skip string-matching the message. The struct is what errors.As unwraps into, and the package ships predicates for the common questions.

type TransportError struct {
Op string // "dial", "tls_handshake", "request", "dns_resolve", ...
Host string
Port string
Protocol string // "h1", "h2", "h3"
Cause error // the underlying net/tls/dns error
Category error // ErrConnection, ErrTLS, ErrDNS, ErrTimeout, ErrProxy, ErrProtocol, ErrRequest, ErrResponse, ErrClosed
Retryable bool
}

TransportError implements Error, Unwrap, and Is. Is(target) matches on either the category sentinel (ErrTLS, ErrDNS, etc.) or the wrapped cause, so errors.Is(err, transport.ErrTLS) works whether the error came back from the lib's TLS dial or from a deeper cause that string-matches a TLS keyword.

The five predicates cover the common branches without you having to reach into the struct yourself:

import "github.com/sardanioss/httpcloak/transport"

if transport.IsTimeout(err) { /* deadline / i/o timeout */ }
if transport.IsTLSError(err) { /* handshake / cert / x509 */ }
if transport.IsDNSError(err) { /* lookup failed */ }
if transport.IsConnectionError(err) { /* refused / reset / unreachable */ }
if transport.IsProxyError(err) { /* proxy hop failed */ }

When you want the structured fields:

var te *transport.TransportError
if errors.As(err, &te) {
log.Printf("op=%s host=%s:%s proto=%s retryable=%v cause=%v",
te.Op, te.Host, te.Port, te.Protocol, te.Retryable, te.Cause)

if te.Retryable {
// one safe place to gate retries: the wrapper already decided
}
}

Retryable is the lib's opinion on whether the underlying cause is worth retrying. Timeouts and DNS failures are flagged retryable; TLS handshake failures and proxy auth errors are not. The retry config (WithRetryConfig) reads this same flag, so a custom retry loop and the built-in retry agree on which errors to bounce.

ALPNMismatchError

A separate type, returned from H2 dials when the server's TLS hello negotiates http/1.1 instead. The error carries the live *utls.UConn so the H1 path can reuse it without a fresh handshake.

var ame *transport.ALPNMismatchError
if errors.As(err, &ame) {
// ame.TLSConn is the open TLS conn, ame.Negotiated is "http/1.1"
}

You usually don't see this surface in user code; the lib handles it internally inside doAuto and the H2 race. It matters when you're driving the transport manually.

HTTPError

Wraps a non-success response in a typed error for callers who explicitly opted into "raise on bad status". Returned from Response.RaiseForStatus() (and the binding equivalents) and from any caller that uses transport.NewHTTPError:

type HTTPError struct {
StatusCode int
Status string
Body []byte
Headers map[string]string
}

(*HTTPError).IsClientError() bool // 4xx
(*HTTPError).IsServerError() bool // 5xx
(*HTTPError).IsRetryable() bool // 408, 425, 429, 500, 502, 503, 504

The default response handler does NOT raise on 4xx/5xx. Go callers check resp.StatusCode directly and construct an *HTTPError with transport.NewHTTPError when they want a typed wrapper. The bindings ship a RaiseForStatus() / raise_for_status() shortcut on the response object, but Go has no equivalent on *Response. See Hooks for a hook-driven pattern that emits HTTPError from a status filter.

Retry guidance

warning

Don't retry on 4xx. The server told you no for a reason. Retrying just hammers it and won't change the outcome. Fix the request.

Rough rules:

SituationRetry?
Network error (DNS, refused, reset)Yes, with backoff
TimeoutYes, but bump the deadline if the upstream is slow
TLS handshake failureNo, fix the config
4xxNo
5xx + idempotent verb (GET, HEAD, PUT, DELETE)Yes
5xx + POST/PATCHOnly if you're sure the server didn't already process it. POST is not idempotent.
429 Too Many RequestsYes, but back off harder. Honor Retry-After if present.

The session has built-in retry support. The default is off. Issue #57 flipped the default from 3 to 0 because the old behavior silently retried POSTs on 5xx and broke idempotency assumptions:

s := httpcloak.NewSession("chrome-latest",
httpcloak.WithRetry(3),
// or fine-grained:
httpcloak.WithRetryConfig(3, 500*time.Millisecond, 10*time.Second, []int{500, 502, 503, 504}),
)

Pass status codes you want to retry on. Don't blindly retry on 4xx.

A timeout test

The easiest way to verify timeout handling is to hit httpbin.org/delay/N with a context shorter than N.

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

_, err := s.Get(ctx, "https://httpbin.org/delay/10")
fmt.Println(err)
// dial httpbin.org [h1]: dial tcp4 ...: i/o timeout
fmt.Println(errors.Is(err, context.DeadlineExceeded))
// true

/delay/10 makes the server sit on the request for 10 seconds. With a 2-second timeout you get back a deadline-exceeded error and no Response.

Logging tip

When debugging unknown failures in prod, log three things:

  1. The full error message (don't strip the wrap chain).
  2. The error's Go type (or Python class) so you can pattern-match later.
  3. The status code and a tail of the body, when a Response made it back.

The error message alone isn't always enough to tell DNS-failed from timeout-on-DNS-server. The type usually is.