arblant
firefox-vpn-client / core / internal / guardian / errors.go
errors.go157 lines · 4.5 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157
package guardian

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/armanlife/firefox-vpn-client/core/internal/controlplane"
)

var (
	// ErrQuotaExceeded signals that the account's monthly proxy quota is
	// exhausted (Guardian responds with HTTP 429).
	ErrQuotaExceeded = errors.New("quota exceeded")

	// ErrTokenInvalid signals that Guardian rejected the request with HTTP
	// 401 (access token rejected) or 403 (account not entitled). Callers
	// with a session token pool rotate to the next token on this error.
	ErrTokenInvalid = errors.New("token invalid")

	// ErrNotEntitled signals HTTP 403: the access token is valid but the
	// account has no proxy entitlement yet; activation can grant it. It
	// is reported together with ErrTokenInvalid.
	ErrNotEntitled = errors.New("account not entitled")

	// ErrRegionUnavailable signals HTTP 451: Guardian does not serve the
	// region of the requesting address. Retrying from the same address
	// does not help.
	ErrRegionUnavailable = errors.New("Guardian does not serve this region")

	// ErrServer signals an HTTP 5xx answer from Guardian.
	ErrServer = errors.New("Guardian server error")
)

// HTTPError carries a non-200 Guardian response. errors.Is matches
// the sentinel errors above according to the status code.
type HTTPError struct {
	Operation  string
	StatusCode int
	Body       string
	// RetryAfter is the delay requested by a Retry-After header, or zero.
	RetryAfter time.Duration
	// Usage holds valid quota headers sent with the error, if any.
	Usage *ProxyUsage
}

func (e *HTTPError) Error() string {
	if kind := e.kind(); kind != nil {
		return fmt.Sprintf("%s returned HTTP %d (%s): %s", e.Operation, e.StatusCode, kind, e.Body)
	}
	return fmt.Sprintf("%s returned HTTP %d: %s", e.Operation, e.StatusCode, e.Body)
}

func (e *HTTPError) kind() error {
	switch {
	case e.StatusCode == http.StatusUnauthorized:
		return ErrTokenInvalid
	case e.StatusCode == http.StatusForbidden:
		return ErrNotEntitled
	case e.StatusCode == http.StatusTooManyRequests:
		return ErrQuotaExceeded
	case e.StatusCode == http.StatusUnavailableForLegalReasons:
		return ErrRegionUnavailable
	case e.StatusCode >= 500 && e.StatusCode <= 599:
		return ErrServer
	}
	return nil
}

func (e *HTTPError) Unwrap() []error {
	switch kind := e.kind(); kind {
	case nil:
		return nil
	case ErrNotEntitled:
		return []error{ErrTokenInvalid, ErrNotEntitled}
	default:
		return []error{kind}
	}
}

// IsTransient reports whether a Guardian request failed in a way
// that a prompt retry can fix: a transport error or an HTTP 5xx answer.
// Context cancellation and every other HTTP status are not transient.
func IsTransient(err error) bool {
	if err == nil || errors.Is(err, context.Canceled) {
		return false
	}
	var httpErr *HTTPError
	if errors.As(err, &httpErr) {
		return errors.Is(err, ErrServer)
	}
	var claimsErr *proxyPassFormatError
	return !errors.As(err, &claimsErr)
}

// RetryAfter returns the server-requested retry delay carried by err,
// or zero.
func RetryAfter(err error) time.Duration {
	var httpErr *HTTPError
	if errors.As(err, &httpErr) {
		return httpErr.RetryAfter
	}
	return 0
}

// UsageFromError returns the quota state carried by err, or nil.
func UsageFromError(err error) *ProxyUsage {
	var httpErr *HTTPError
	if errors.As(err, &httpErr) {
		return httpErr.Usage
	}
	return nil
}

// proxyPassFormatError marks a 200 response whose pass cannot be used; a
// retry would only fetch another malformed pass.
type proxyPassFormatError struct{ err error }

func (e *proxyPassFormatError) Error() string { return e.err.Error() }
func (e *proxyPassFormatError) Unwrap() error { return e.err }

func newHTTPError(operation string, resp *http.Response) *HTTPError {
	err := &HTTPError{
		Operation:  operation,
		StatusCode: resp.StatusCode,
		Body:       controlplane.ReadErrorBody(resp.Body),
	}
	if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
		err.RetryAfter = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now())
	}
	if usage, usageErr := parseProxyUsage(resp.Header); usageErr == nil {
		err.Usage = usage
	}
	return err
}

// parseRetryAfter accepts delay-seconds or an HTTP-date.
func parseRetryAfter(value string, now time.Time) time.Duration {
	value = strings.TrimSpace(value)
	if value == "" {
		return 0
	}
	if seconds, err := strconv.ParseInt(value, 10, 64); err == nil {
		if seconds <= 0 {
			return 0
		}
		return time.Duration(seconds) * time.Second
	}
	if at, err := http.ParseTime(value); err == nil && at.After(now) {
		return at.Sub(now)
	}
	return 0
}