Charging

The billing job’s payments package: the charge request and its idempotency key, what each answer from the payments API means for the invoice, and the Classify that decides which answers are retried.

The payments API is invented for the walkthrough, and is one endpoint: POST /v1/charges, with a bearer key, an Idempotency-Key, and the account, amount and currency as JSON.

A charge and its result

Start the payments package with what the run hands it, and what it hands back:

type Charge struct {
	ID          uuid.UUID
	AccountID   string
	AmountCents int64
	Currency    string
}
type State int

const (
	Charged State = iota + 1
	Declined
	Failed
)

type Result struct {
	State       State
	ChargeID    string
	DeclineCode string
	Answer      string
}

Classify

httpclient’s default Classify, plus two answers. A 409 means a charge with this key is still in flight, so it is retried, and every 5xx counts against the breaker:

var defaultClassify = httpclient.DefaultSettings().Classify

func Classify(resp *http.Response, err error) httpclient.Classification {
	if err != nil {
		return defaultClassify(resp, err)
	}
	if resp.StatusCode == http.StatusConflict {
		return httpclient.Classification{Retry: retry.Retry, Breaker: circuit.Success}
	}
	c := defaultClassify(resp, err)
	if resp.StatusCode >= 500 && resp.StatusCode <= 599 {
		c.Breaker = circuit.Failure
	}
	return c
}

The client

NewClient builds an httpclient client with that Classify. ForwardScenario is false, so a failure-injection scenario never reaches the payments API:

type Client struct {
	http    *http.Client
	charges string
	key     string
}

func NewClient(s *humus.Setup, settings httpclient.Settings, key string) (*Client, error) {
	settings.Classify = Classify
	settings.ForwardScenario = false
	c, err := httpclient.NewClient(s, settings)
	if err != nil {
		return nil, err
	}
	return &Client{
		http:    c,
		charges: settings.BaseURL.JoinPath("v1", "charges").String(),
		key:     key,
	}, nil
}

The request and its key

The body and the answer, as JSON:

type charge struct {
	Account  string `json:"account"`
	Amount   int64  `json:"amount"`
	Currency string `json:"currency"`
}

type answer struct {
	ID   string `json:"id"`
	Code string `json:"code"`
}

The request carries the key, and the invoice’s ID as its Idempotency-Key:

func (c *Client) Charge(ctx context.Context, ch Charge) (Result, error) {
	body, err := json.Marshal(charge{Account: ch.AccountID, Amount: ch.AmountCents, Currency: ch.Currency})
	if err != nil {
		return Result{}, fmt.Errorf("charging invoice %s: %w", ch.ID, err)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.charges, bytes.NewReader(body))
	if err != nil {
		return Result{}, fmt.Errorf("charging invoice %s: %w", ch.ID, err)
	}
	req.Header.Set("Authorization", "Bearer "+c.key)
	req.Header.Set("Idempotency-Key", ch.ID.String())
	req.Header.Set("Content-Type", "application/json")

Retrying a POST is safe only because it carries that key. The body is a *bytes.Reader, so httpclient can send it again.

The answers

The client sends it, then reads at most 64 KiB of the answer:

resp, err := c.http.Do(req)
if errors.Is(err, circuit.ErrRejected) {
	return Result{}, fmt.Errorf("charging invoice %s: %w: %w", ch.ID, ErrStop, err)
}
if err != nil {
	return Result{}, fmt.Errorf("charging invoice %s: %w", ch.ID, err)
}
defer func() { _ = resp.Body.Close() }()
b, err := io.ReadAll(io.LimitReader(resp.Body, maxAnswer))
if err != nil {
	return Result{}, fmt.Errorf("charging invoice %s: reading the answer to %s: %w", ch.ID, resp.Status, err)
}

Each answer becomes a result, or an error that leaves the invoice pending:

switch code := resp.StatusCode; {
case code == http.StatusCreated, code == http.StatusOK:
	var a answer
	if err := json.Unmarshal(b, &a); err != nil || a.ID == "" {
		return Result{State: Failed, Answer: answerOf(resp, b)}, nil
	}
	return Result{State: Charged, ChargeID: a.ID}, nil
case code == http.StatusPaymentRequired:
	var a answer
	_ = json.Unmarshal(b, &a)
	return Result{State: Declined, DeclineCode: a.Code}, nil
case code == http.StatusUnauthorized, code == http.StatusForbidden:
	return Result{}, fmt.Errorf("charging invoice %s: %w: the payments API rejected the key: %s", ch.ID, ErrStop, answerOf(resp, b))
case code == http.StatusConflict, code == http.StatusTooManyRequests, code >= 500:
	return Result{}, fmt.Errorf("charging invoice %s: the payments API answered %s", ch.ID, answerOf(resp, b))
default:
	return Result{State: Failed, Answer: answerOf(resp, b)}, nil
}
The answerThe invoice
201, or 200 with the charge already made for the keycharged, with its ID
402declined, with its code
401, 403still pending, and the run stops
409, 429, 5xx or a transport error, after any retriesstill pending
any other answerfailed, with the answer, for a person to look at
rejected by the open breakerstill pending, and the run stops

ErrStop marks the errors that stop the run, because every further charge would fail too:

const maxAnswer = 64 << 10

var ErrStop = errors.New("every further charge would fail too")
func answerOf(resp *http.Response, body []byte) string {
	b := strings.TrimSpace(string(body))
	if b == "" {
		return resp.Status
	}
	return resp.Status + ": " + b
}