Calling the catalog

The cart’s calls to the catalog: an httpclient client over mTLS with its default retries and breaker, ForwardScenario, the catalogclient package that turns each of the catalog’s answers into the cart’s, and the readiness call through the same client.

The cart has one httpclient client for the catalog. Prices and readiness both go through it.

The client’s settings

The cart’s readSettings starts from httpclient.DefaultSettings(), and sets the catalog’s URL, the CA it trusts the catalog through, and the client certificate it presents:

catalog := httpclient.DefaultSettings()
catalog.BaseURL = r.Get(env("CATALOG_URL").Parse(url.Parse).Validate(checkCatalogURL))
catalog.TLS = &tls.Config{
	RootCAs: r.Get(file("CATALOG_CA_FILE").Parse(certPool)),
	GetClientCertificate: r.Get(env("CATALOG_CLIENT_CERT_FILE").Validate(holdsCertificate(s.Host.FS)).FlatMap(func(certFile string) config.Value[clientCertificate] {
		return env("CATALOG_CLIENT_KEY_FILE").Parse(func(keyFile string) (clientCertificate, error) {
			cfg, err := certfile.Config(s.Host.FS, certFile, keyFile)
			if err != nil {
				return nil, err
			}
			return cfg.GetClientCertificate, nil
		})
	})),
}
catalog.Timeout = r.Get(env("CATALOG_TIMEOUT").Parse(time.ParseDuration).Default(catalog.Timeout).Validate(httpclient.CheckTimeout))
catalog.AttemptTimeout = r.Get(env("CATALOG_ATTEMPT_TIMEOUT").Parse(time.ParseDuration).Default(catalog.AttemptTimeout).Validate(httpclient.CheckAttemptTimeout))
catalog.MaxAttempts = r.Get(env("CATALOG_MAX_ATTEMPTS").Parse(strconv.Atoi).Default(catalog.MaxAttempts).Validate(httpclient.CheckMaxAttempts))
catalog.ForwardScenario = true
type clientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error)

The catalog asks for a client certificate, so its URL must be https:

func checkCatalogURL(u *url.URL) error {
	if err := httpclient.CheckBaseURL(u); err != nil {
		return err
	}
	if u.Scheme != "https" {
		return errors.New("must be an https URL")
	}
	return nil
}

Retries and the breaker

The cart keeps the default Classify, retry budget and breaker. A price is a GET, so it is safe to retry:

The catalog’s answerRetriedTo the breaker
200, or a 404noa success
502, 503 or 504yesa failure
a refused or reset connection, or an AttemptTimeoutyesa failure
a 500noa failure

A 404 is the catalog working, so it counts for the catalog, not against it. Calling HTTP services describes the default.

ForwardScenario

ForwardScenario sends a call’s failure-injection scenario on to the catalog, because the catalog is the walkthrough’s own and reads the same codec. So a scenario the cart trusts can fail the catalog’s postgresql:catalog point too, and the catalog trusts it because the cart’s client certificate verified.

The client and the Catalog

openCatalog builds the client, and the two things the cart needs from it, a Catalog and a readiness check:

func openCatalog(s *humus.Setup, settings httpclient.Settings) (endpoint.Catalog, func(context.Context) error, error) {
	client, err := httpclient.NewClient(s, settings)
	if err != nil {
		return nil, nil, err
	}
	return catalogclient.New(client, settings.BaseURL), readiness(client, settings.BaseURL.JoinPath("health", "readiness")), nil
}

catalogclient decodes the catalog’s JSON into a type of its own, and imports nothing from catalog/, as a client of another team’s service couldn’t:

type Client struct {
	http *http.Client
	base *url.URL
}

func New(client *http.Client, baseURL *url.URL) *Client {
	return &Client{http: client, base: baseURL}
}

type product struct {
	UnitPrice *float64 `json:"unitPrice"`
}

const maxBody = 64 << 10

The request

Price escapes the product’s ID into the path:

func (c *Client) Price(ctx context.Context, productID string) (float64, error) {
	u := c.base.JoinPath("products")
	u.RawPath = u.EscapedPath() + "/" + url.PathEscape(productID)
	u.Path += "/" + productID

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
	if err != nil {
		return 0, fmt.Errorf("getting product %s: %w", productID, err)
	}
	req.Header.Set("Accept", "application/json")
	resp, err := c.http.Do(req)
	if err != nil {
		return 0, fmt.Errorf("getting product %s: %w: %w", productID, endpoint.ErrCatalogUnavailable, err)
	}
	defer func() {
		_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxBody))
		_ = resp.Body.Close()
	}()

An error from Do is the catalog unavailable: it couldn’t be reached after the retries, the call’s Timeout passed, or the breaker rejected the call.

The answers

Each status becomes a price, or an error the cart’s operations answer:

switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
	return 0, fmt.Errorf("getting product %s: %w", productID, endpoint.ErrProductNotFound)
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
	return 0, fmt.Errorf("getting product %s: %w: the catalog answered %d", productID, endpoint.ErrCatalogUnavailable, resp.StatusCode)
default:
	return 0, fmt.Errorf("getting product %s: the catalog answered %d", productID, resp.StatusCode)
}

var p product
if err := json.NewDecoder(io.LimitReader(resp.Body, maxBody)).Decode(&p); err != nil {
	return 0, fmt.Errorf("getting product %s: decoding the catalog's answer: %w", productID, err)
}
switch {
case p.UnitPrice == nil:
	return 0, fmt.Errorf("getting product %s: the catalog answered with no unitPrice", productID)
case *p.UnitPrice < 0:
	return 0, fmt.Errorf("getting product %s: the catalog answered with a negative unitPrice", productID)
}
return *p.UnitPrice, nil
The catalogPrice returnsThe cart answers
answers 200 with a unitPrice of 0 or morethe price201, with the item
answers 404ErrProductNotFound422
answers 502, 503 or 504, or can’t be reachedErrCatalogUnavailable503, the catalog can’t be reached
is shed by the breakerErrCatalogUnavailable, wrapping circuit.ErrRejected503, the cart isn’t calling it
answers anything elsean error500

The two 503s are answer’s:

case errors.Is(err, ErrCatalogUnavailable) && errors.Is(err, circuit.ErrRejected):
	return unavailable("The cart isn't calling the catalog right now, since it has been failing. Try again later.")
case errors.Is(err, ErrCatalogUnavailable):
	return unavailable("The cart can't reach the catalog right now. Try again later.")

Readiness

The cart’s readiness asks the catalog’s, through the same client:

func readiness(client *http.Client, u *url.URL) func(context.Context) error {
	return func(ctx context.Context) error {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
		if err != nil {
			return err
		}
		resp, err := client.Do(req)
		if err != nil {
			return err
		}
		defer func() {
			_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxBodyBytes))
			_ = resp.Body.Close()
		}()
		if resp.StatusCode != http.StatusOK {
			return fmt.Errorf("the catalog's readiness answered %d", resp.StatusCode)
		}
		return nil
	}
}

So it proves what adding an item needs: the URL, a catalog certificate the CA signed, a client certificate the catalog trusts, and a catalog that has reached its own database. Init latches it.