package store
import (
"errors"
"fmt"
"time"
)
var ErrNotFound = errors.New("resource not found")
type RateLimitError struct {
RetryAfter time.Duration
}
func (e *RateLimitError) Error() string {
return fmt.Sprintf("rate limited, retry after %s", e.RetryAfter)
}
package store
import (
"fmt"
"io"
"net/http"
"strconv"
"time"
)
type Client struct {
HTTP *http.Client
}
func (c *Client) Fetch(url string) ([]byte, error) {
resp, err := c.HTTP.Get(url)
if err != nil {
return nil, fmt.Errorf("fetch %q: %w", url, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body %q: %w", url, err)
}
return body, nil
case http.StatusNotFound:
return nil, fmt.Errorf("fetch %q: %w", url, ErrNotFound)
case http.StatusTooManyRequests:
secs, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
rl := &RateLimitError{RetryAfter: time.Duration(secs) * time.Second}
return nil, fmt.Errorf("fetch %q: %w", url, rl)
default:
return nil, fmt.Errorf("fetch %q: unexpected status %d", url, resp.StatusCode)
}
}
package store
import (
"errors"
"log"
"time"
)
func LoadProfile(c *Client, url string) ([]byte, error) {
for attempt := 0; attempt < 3; attempt++ {
body, err := c.Fetch(url)
if err == nil {
return body, nil
}
if errors.Is(err, ErrNotFound) {
log.Printf("profile missing, giving up: %v", err)
return nil, err
}
var rlErr *RateLimitError
if errors.As(err, &rlErr) {
log.Printf("backing off for %s: %v", rlErr.RetryAfter, err)
time.Sleep(rlErr.RetryAfter)
continue
}
return nil, err
}
return nil, errors.New("load profile: retries exhausted")
}
This snippet shows the idiomatic Go pattern for adding context to errors as they bubble up a call stack while keeping them programmatically inspectable. The core idea is that fmt.Errorf with the %w verb wraps an underlying error rather than flattening it into an opaque string, so callers can still ask questions about the original cause using errors.Is and errors.As.
errors.go defines the building blocks. ErrNotFound is a sentinel value — a package-level error compared by identity — which suits conditions that carry no extra data. RateLimitError is a typed error: it implements the error interface and carries a RetryAfter field, so consumers need the concrete type to read that field. This is the key distinction: sentinels answer "is this that specific condition?" while typed errors answer "give me the structured details".
store.go is the layer that produces those errors. Fetch maps HTTP status codes onto the error vocabulary, wrapping each with %w so the request URL travels along as human context without hiding the machine-readable cause. A 404 wraps ErrNotFound; a 429 wraps a freshly built RateLimitError whose RetryAfter is parsed from the response header. Because both are wrapped, the string you log is descriptive and the chain stays intact.
consumer.go demonstrates inspection at the top of the stack. errors.Is(err, ErrNotFound) walks the wrap chain looking for the sentinel, matching regardless of how many %w layers sit on top. errors.As(err, &rlErr) searches the same chain for the first value assignable to a *RateLimitError, populating it so rlErr.RetryAfter becomes accessible.
The trade-off worth understanding: wrapping with %w makes an error part of your package's API, since callers may now depend on unwrapping it — use %v instead when the cause should stay private. A common pitfall is reaching for string matching (strings.Contains(err.Error(), ...)), which is brittle; errors.Is/errors.As are the correct tools. Reach for this pattern whenever an error must survive several layers yet still drive a decision, such as retry logic, at the boundary.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
package api
import (
"io"
"net/http"
"os"
Safe multipart uploads using temp files (bounded memory)
package deps
import (
"crypto/tls"
"crypto/x509"
"net/http"
mTLS client configuration with custom root CA pool
class CreateDeadJobs < ActiveRecord::Migration[7.1]
def change
create_table :dead_jobs do |t|
t.string :jid, null: false
t.string :queue, null: false
t.string :klass, null: false
Background Job Dead Letter Queue (DLQ) Table
Share this code
Here's the card — post it anywhere.