Calling the provider

The mailer’s provider package: an httpclient client with a Classify that retries a 409, the request with its Idempotency-Key and API key, what each answer means, Retry-After, and the WireMock mappings that stand in for the provider.

The provider is invented for the walkthrough, and is one endpoint: POST /v1/messages, with a bearer key, an Idempotency-Key, and the sender, recipient, subject and text as JSON. It answers 2xx with the email’s id, or a 4xx with a reason.

Its settings and its result

Start the provider package with what Init hands it, and what a send hands back:

type Settings struct {
	URL            *url.URL
	CA             *x509.CertPool
	Timeout        time.Duration
	AttemptTimeout time.Duration
	MaxAttempts    int
	From           string
	APIKey         string
}

type Result struct {
	Refused bool
	ID      string
	Reason  string
}

The client

New checks the settings, then builds an httpclient client for the provider’s URL, trusting CA, or the system’s roots when it’s nil:

type Client struct {
	http          *http.Client
	messages      string
	from          string
	authorization string
}

func New(s *humus.Setup, settings Settings) (*Client, error) {
	var errs []error
	if settings.From == "" {
		errs = append(errs, errors.New("Settings.From must not be empty"))
	}
	if settings.APIKey == "" {
		errs = append(errs, errors.New("Settings.APIKey must not be empty"))
	}
	plain := settings.URL != nil && settings.URL.Scheme == "http"
	if plain && settings.CA != nil {
		errs = append(errs, errors.New("Settings.CA must be nil for an http Settings.URL, which is plain text"))
	}
	if err := errors.Join(errs...); err != nil {
		return nil, fmt.Errorf("provider: New: %w", err)
	}

	h := httpclient.DefaultSettings()
	h.BaseURL = settings.URL
	if !plain {
		h.TLS = &tls.Config{RootCAs: settings.CA}
	}
	h.Timeout = settings.Timeout
	h.AttemptTimeout = settings.AttemptTimeout
	h.MaxAttempts = settings.MaxAttempts
	h.Classify = withConflict(h.Classify)
	c, err := httpclient.NewClient(s, h)
	if err != nil {
		return nil, fmt.Errorf("provider: New: %w", err)
	}
	return &Client{
		http:          c,
		messages:      settings.URL.JoinPath("v1", "messages").String(),
		from:          settings.From,
		authorization: "Bearer " + settings.APIKey,
	}, nil
}

The API key is set once, as the Authorization header’s value. Init reads it from a file.

Classify

The default Classify, plus one answer. The provider answers 409 while a request with the same key is in flight, such as when a redelivery reaches a second replica, so a 409 is retried and counts for the provider, not against it:

func withConflict(classify func(*http.Response, error) httpclient.Classification) func(*http.Response, error) httpclient.Classification {
	return func(resp *http.Response, err error) httpclient.Classification {
		if resp != nil && resp.StatusCode == http.StatusConflict {
			return httpclient.Classification{Retry: retry.Retry, Breaker: circuit.Success}
		}
		return classify(resp, err)
	}
}

Retry-After

The default Classify waits out a 429’s or a 503’s Retry-After. A Retry-After longer than BackoffMax ends the call instead, and the request waits in the delay queue, so a rate limit holds no worker.

The request and its key

The body and the two answers, as JSON:

type message struct {
	From    string `json:"from"`
	To      string `json:"to"`
	Subject string `json:"subject"`
	Text    string `json:"text"`
}

type accepted struct {
	ID string `json:"id"`
}

type refused struct {
	Reason string `json:"reason"`
}

The request’s MessageId is its Idempotency-Key:

func (c *Client) Send(ctx context.Context, r mail.Request) (Result, error) {
	b, err := json.Marshal(message{From: c.from, To: r.To, Subject: r.Subject, Text: r.Text})
	if err != nil {
		return Result{}, fmt.Errorf("provider: encoding the message: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.messages, bytes.NewReader(b))
	if err != nil {
		return Result{}, fmt.Errorf("provider: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", r.MessageID)
	req.Header.Set("Authorization", c.authorization)

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 err != nil {
	return Result{}, fmt.Errorf("provider: %w", err)
}
defer func() { _ = resp.Body.Close() }()
answer, err := io.ReadAll(io.LimitReader(resp.Body, maxAnswerSize))
if err != nil {
	return Result{}, fmt.Errorf("provider: reading its %s answer: %w", resp.Status, err)
}
const maxAnswerSize = 64 << 10

Each answer becomes a result, or an error:

switch code := resp.StatusCode; {
case code >= 200 && code < 300:
	var a accepted
	if err := json.Unmarshal(answer, &a); err != nil || a.ID == "" {
		return Result{}, fmt.Errorf("provider: it answered %s without an id", resp.Status)
	}
	return Result{ID: a.ID}, nil
case code >= 400 && code < 500 && code != http.StatusRequestTimeout && code != http.StatusConflict && code != http.StatusTooManyRequests:
	var a refused
	if err := json.Unmarshal(answer, &a); err != nil || a.Reason == "" {
		return Result{}, fmt.Errorf("provider: it answered %s without a reason", resp.Status)
	}
	return Result{Refused: true, Reason: a.Reason}, nil
default:
	return Result{}, fmt.Errorf("provider: it answered %s", resp.Status)
}
The answer, after any retriesSend returns
2xx with an idthe id
a 4xx other than 408, 409 and 429, with a reasonRefused, with the reason
anything else, a transport error, or the breaker openan error

An error is a failure for now, and the request is retried later.

WireMock’s mappings

WireMock stands in for the provider, in the kind overlay and the container tests, with three mappings in deploy/kind/wiremock/. The lowest priority is matched first.

No bearer token is a 401:

{
  "priority": 1,
  "request": {
    "method": "POST",
    "url": "/v1/messages",
    "headers": {
      "Authorization": {
        "or": [
          { "absent": true },
          { "doesNotMatch": "^Bearer \\S+$" }
        ]
      }
    }
  },
  "response": {
    "status": 401,
    "headers": {
      "Content-Type": "application/json"
    },
    "jsonBody": {
      "reason": "no bearer token"
    }
  }
}

An address in .invalid, a domain that can’t exist, is refused:

{
  "priority": 2,
  "request": {
    "method": "POST",
    "url": "/v1/messages",
    "bodyPatterns": [
      {
        "matchesJsonPath": {
          "expression": "$.to",
          "matches": "(?i)^[^@]+@([^@.]+\\.)*invalid$"
        }
      }
    ]
  },
  "response": {
    "status": 422,
    "headers": {
      "Content-Type": "application/json"
    },
    "jsonBody": {
      "reason": "the recipient's domain does not exist"
    }
  }
}

Every other request is accepted, with an ID made from its Idempotency-Key, so the same key always gets the same ID:

{
  "priority": 3,
  "request": {
    "method": "POST",
    "url": "/v1/messages"
  },
  "response": {
    "status": 202,
    "headers": {
      "Content-Type": "application/json"
    },
    "body": "{\"id\":\"msg_{{request.headers.Idempotency-Key}}\"}",
    "transformers": ["response-template"]
  }
}