Skip to main content

Go

net/http and encoding/json are all you need.

Types worth declaring

type SendRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
}

type APIError struct {
Status int `json:"status"`
Type string `json:"type"`
Message string `json:"message"`
RequestID string `json:"request_id"`
DocURL string `json:"doc_url"`
Details []struct {
Field string `json:"field"`
Message string `json:"message"`
Code string `json:"code"`
} `json:"details"`
}

func (e *APIError) Error() string {
return e.Type + ": " + e.Message + " (" + e.RequestID + ")"
}

Decoding the envelope into a typed error is what lets callers use errors.As instead of matching on strings.

Send

func Send(ctx context.Context, client *http.Client, key string, send SendRequest, idempotencyKey string) (string, error) {
body, err := json.Marshal(send)
if err != nil {
return "", err
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.epostix.com/v1/emails", bytes.NewReader(body))
if err != nil {
return "", err
}

req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")

if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}

resp, err := client.Do(req)
if err != nil {
return "", err
}

defer resp.Body.Close()

if resp.StatusCode >= 400 {
apiErr := &APIError{}
if decodeErr := json.NewDecoder(resp.Body).Decode(apiErr); decodeErr != nil {
return "", decodeErr
}

return "", apiErr
}

var created struct {
ID string `json:"id"`
}

if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
return "", err
}

return created.ID, nil
}

Retry on the right errors only

var apiErr *APIError
if errors.As(err, &apiErr) {
switch apiErr.Type {
case "rate_limit_exceeded", "daily_quota_exceeded", "idempotency_in_progress":
// honour Retry-After, then send the identical request again
case "internal_error", "service_unavailable":
// back off, reusing the same Idempotency-Key
default:
// the request is wrong; do not retry it
}
}

Give the client a timeout. http.DefaultClient has none, so a stalled connection blocks the goroutine indefinitely.

client := &http.Client{Timeout: 30 * time.Second}

Next

  • Idempotency for keys that survive a restart
  • Errors for every type and whether it is retryable