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