Skip to content

Calling other services

Outgoing HTTP carries the caller's deadline and trace, which is why it is a function of a context rather than a method on one.

The HTTP client

core.Requester(ctx) — not ctx.Requester(). Reading an upstream's error body with SetError when you need a field to decide something, a per-call deadline derived from the request rather than from Background, and a retry condition that knows a retried POST is a second charge. See Requester.

go
package main

import (
	"context"
	"net/http"
	"time"

	"github.com/go-resty/resty/v2"

	core "gitlab.finema.co/finema/idin-core/v2"
)

// errPaymentUnavailable is one of our own error values, not the upstream's.
// Reusable values like this belong in the service's own errmsgs package: the
// framework only ships the generic ones.
var errPaymentUnavailable = core.New(
	http.StatusServiceUnavailable, "PAYMENT_UNAVAILABLE", "payment provider is unavailable")

// --- Example 1: calling somebody else's API ---------------------------------
//
// core.Requester(ctx), not ctx.Requester(). An outgoing call is not a capability
// the request *has* — it is something the code goes and does, carrying that
// request's deadline and trace. Taking only a context.Context is what lets a
// domain service deep in the call stack make one without changing its signature
// to core.IContext.
//
// The API is deliberately thin: build the request with the full go-resty API via
// R(), run it with Send. Send is the only thing the framework wraps — it binds
// the context and turns every failure, transport error or non-2xx status, into a
// core.IError you can return straight from a handler.

type Rate struct {
	Pair  string  `json:"pair"`
	Value float64 `json:"value"`
}

// fetchRate takes a plain context.Context and still gets the client configured at
// startup: same pool, same middleware, same instrumentation. A context that came
// from nowhere (context.Background() in a script) gets a plain default client
// rather than nil, so the call site never has to nil-check — but that one is not
// instrumented, because there is no App to ask how it was configured.
func fetchRate(ctx context.Context, baseURL, pair string) (*Rate, core.IError) {
	r := core.Requester(ctx)

	var out Rate
	if _, err := r.Send(
		r.R().SetQueryParam("pair", pair).SetResult(&out),
		http.MethodGet, baseURL+"/v1/rates",
	); err != nil {
		return nil, err
	}
	return &out, nil
}

// chargeError is the upstream's error body — the fields their {code, message}
// does not carry.
type chargeError struct {
	Code        string `json:"code"`
	Message     string `json:"message"`
	DeclineCode string `json:"decline_code"`
	Retryable   bool   `json:"retryable"`
}

// charge shows SetResult and SetError together. SetResult binds the success
// body, SetError the failure one; resty decodes whichever applies.
//
// Most of the time SetError is unnecessary: an upstream that answers with
// {code, message} is already translated by Send. Reach for it when you need a
// field to *decide* something — whether to retry, what to tell the user, which
// of your own error codes this maps to.
func charge(ctx context.Context, baseURL string, amount int64) core.IError {
	r := core.Requester(ctx)

	var out struct {
		ID string `json:"id"`
	}
	var fail chargeError

	_, err := r.Send(
		r.R().
			SetBody(map[string]any{"amount": amount}).
			SetResult(&out).
			SetError(&fail),
		http.MethodPost, baseURL+"/v1/charges",
	)
	if err == nil {
		return nil
	}

	// err is already an IError carrying the upstream's status and code; fail is
	// the detail that made the branch possible. Note that `out` is untouched on
	// failure — reading it would give zero values, not half a charge.
	if fail.Retryable {
		return errPaymentUnavailable
	}
	// never hand an upstream's raw wording to our own users
	return core.Wrap(err, "charge declined: "+fail.DeclineCode).
		WithStatus(http.StatusPaymentRequired).
		WithCode("CARD_DECLINED")
}

// callWithDeadline bounds one call without touching the shared client's timeout.
//
// Derive from the request context, never from context.Background(): background
// loses the App (so you get an unconfigured, uninstrumented client) and, worse,
// detaches the call from the request's cancellation — a request the client
// abandoned would keep holding the upstream open for the full timeout. Deriving
// also means the shorter deadline always wins, so a request with two seconds
// left does not wait five for this.
func callWithDeadline(parent core.IContext, url string) core.IError {
	ctx, cancel := context.WithTimeout(parent, 2*time.Second)
	defer cancel()

	r := core.Requester(ctx)
	_, err := r.Send(r.R(), http.MethodGet, url)
	return err
}

// bootRequester is where client-level configuration belongs: once, at startup,
// not rebuilt per request.
//
// The retry condition is the part worth reading. Retrying is safe for a request
// the upstream can receive twice — GET, PUT, DELETE — and unsafe for a POST that
// creates something, unless the upstream honours an idempotency key. A blanket
// SetRetryCount(3) on a payments client is how one charge becomes three.
func bootRequester(baseURL string) core.IRequester {
	rc := resty.New().
		// per-destination, not a global 30s: an API that normally answers in
		// 200ms should not be given thirty seconds before we give up on it
		SetTimeout(5 * time.Second).
		SetBaseURL(baseURL).
		SetRetryCount(3).
		SetRetryWaitTime(200 * time.Millisecond).
		SetRetryMaxWaitTime(2 * time.Second).
		AddRetryCondition(func(resp *resty.Response, err error) bool {
			if resp == nil || resp.Request == nil {
				return false // transport failure: safety depends on the method
			}
			switch resp.Request.Method {
			case http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodHead:
			default:
				return false
			}
			return resp.StatusCode() == http.StatusTooManyRequests || resp.StatusCode() >= 500
		})

	return core.NewRequesterWithClient(rc)
}

// uploadRequester is a second client for a different job. SetTimeout is a
// ceiling on the *whole* request including sending the body, so the 30-second
// default cuts off a large file on a slow connection — and a longer context does
// not help, because whichever deadline expires first wins.
func uploadRequester() core.IRequester {
	return core.NewRequesterWithClient(resty.New().SetTimeout(10 * time.Minute))
}

Maintained by Passakon Puttasuwan & Dev Core Team.