Skip to content
/ go-usps Public

A lightweight Golang client for the USPS API

License

Notifications You must be signed in to change notification settings

my-eq/go-usps

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

56 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

go-usps

A lightweight, production-grade Go client library for the USPS Addresses 3.0 REST API and OAuth 2.0 API.

Go Reference Go Report Card CI CodeQL Markdown Lint

Enterprise-grade address validation and standardization for Go applications.

Why go-usps?

  • 🎯 Complete Coverage - All USPS Addresses 3.0 and OAuth 2.0 endpoints
  • 🔒 Automatic OAuth - Built-in token management with automatic refresh
  • 💪 Strongly Typed - Full type safety based on OpenAPI specification
  • 📦 Zero Dependencies - Only uses Go standard library
  • 🏗️ Production Ready - Built with enterprise-grade patterns and best practices
  • 🧪 Fully Tested - 97%+ test coverage with comprehensive test suite

Table of Contents


Quick Start

Get started in 60 seconds:

go get github.com/my-eq/go-usps
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

func main() {
    // Create client with automatic OAuth (recommended)
    client := usps.NewClientWithOAuth("your-client-id", "your-client-secret")

    // Standardize an address
    req := &models.AddressRequest{
        StreetAddress: "123 Main St",
        City:          "New York",
        State:         "NY",
    }

    resp, err := client.GetAddress(context.Background(), req)
    if err != nil {
        log.Fatalf("Error: %v", err)
    }

    fmt.Printf("Standardized: %s, %s, %s %s\n",
        resp.Address.StreetAddress,
        resp.Address.City,
        resp.Address.State,
        resp.Address.ZIPCode)
}

Get your credentials: Register at USPS Developer Portal


Core Concepts

Understanding USPS Address Validation

The USPS Addresses API provides real-time validation and standardization of US domestic addresses. It ensures addresses are deliverable, corrects common errors, and enriches data with ZIP+4 codes and delivery point information.

Key Benefits:

  • Reduce returns - Validate shipping addresses before fulfillment
  • Improve deliverability - Standardize formats to USPS specifications
  • Save costs - Catch errors before packages are shipped
  • Enhance data quality - Fill in missing ZIP codes and abbreviations

Three Core Endpoints

1. Address Standardization (GetAddress)

Validates and standardizes complete addresses. Returns the official USPS format with ZIP+4 codes, delivery point validation, and carrier route information.

req := &models.AddressRequest{
    StreetAddress:    "123 Main St",
    SecondaryAddress: "Apt 4B",  // Optional
    City:             "New York",
    State:            "NY",
}

resp, err := client.GetAddress(ctx, req)
// Returns: standardized address + ZIP+4 + delivery info

Use when: You have a complete address and need to validate or standardize it.

2. City/State Lookup (GetCityState)

Returns the official city and state names for a given ZIP code.

req := &models.CityStateRequest{
    ZIPCode: "10001",
}

resp, err := client.GetCityState(ctx, req)
// Returns: "NEW YORK, NY"

Use when: You have a ZIP code and need the corresponding city and state.

3. ZIP Code Lookup (GetZIPCode)

Returns the ZIP code and ZIP+4 for a given address.

req := &models.ZIPCodeRequest{
    StreetAddress: "123 Main St",
    City:          "New York",
    State:         "NY",
}

resp, err := client.GetZIPCode(ctx, req)
// Returns: ZIP code + ZIP+4

Use when: You have an address without a ZIP code or need to find the ZIP+4.

Authentication

All USPS API requests require OAuth 2.0 authentication. This library handles it automatically.

Recommended approach (automatic token management):

client := usps.NewClientWithOAuth("client-id", "client-secret")
// Tokens are automatically acquired and refreshed

Alternative (manual token provider):

tokenProvider := usps.NewStaticTokenProvider("your-access-token")
client := usps.NewClient(tokenProvider)

Tokens expire after 8 hours but are automatically refreshed 5 minutes before expiration when using NewClientWithOAuth or NewOAuthTokenProvider.

Error Handling

The library provides structured errors with detailed information:

resp, err := client.GetAddress(ctx, req)
if err != nil {
    if apiErr, ok := err.(*usps.APIError); ok {
        // API-specific error
        fmt.Printf("API Error: %s\n", apiErr.ErrorMessage.Error.Message)
        for _, detail := range apiErr.ErrorMessage.Error.Errors {
            fmt.Printf("  - %s: %s\n", detail.Title, detail.Detail)
        }
    } else {
        // Network or other error
        fmt.Printf("Error: %v\n", err)
    }
    return
}

Environments

The library supports both production and testing environments:

// Production (default)
client := usps.NewClientWithOAuth(clientID, clientSecret)

// Testing
client := usps.NewTestClientWithOAuth(clientID, clientSecret)

Usage Examples

E-commerce: Validate Checkout Addresses

Prevent shipping errors by validating customer addresses during checkout:

import (
    "context"
    "fmt"
    "os"
    "time"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

func ValidateShippingAddress(street, city, state, zip string) (*models.AddressResponse, error) {
    client := usps.NewClientWithOAuth(os.Getenv("USPS_CLIENT_ID"),
                                      os.Getenv("USPS_CLIENT_SECRET"))

    req := &models.AddressRequest{
        StreetAddress: street,
        City:          city,
        State:         state,
        ZIPCode:       zip,
    }

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    resp, err := client.GetAddress(ctx, req)
    if err != nil {
        if apiErr, ok := err.(*usps.APIError); ok {
            return nil, fmt.Errorf("invalid address: %s", apiErr.ErrorMessage.Error.Message)
        }
        return nil, err
    }

    return resp, nil
}

Bulk Address Processing

Process large batches of addresses efficiently with built-in rate limiting and retry logic:

import (
    "context"
    "fmt"
    "log"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

func ProcessAddresses(addresses []*models.AddressRequest) {
    client := usps.NewClientWithOAuth(clientID, clientSecret)

    // Configure bulk processor
    config := &usps.BulkConfig{
        MaxConcurrency:    10,  // Process 10 addresses concurrently
        RequestsPerSecond: 10,  // Rate limit to 10 requests/second
        MaxRetries:        3,   // Retry failed requests up to 3 times
        ProgressCallback: func(completed, total int, err error) {
            fmt.Printf("Progress: %d/%d\n", completed, total)
            if err != nil {
                log.Printf("Error processing item: %v", err)
            }
        },
    }

    processor := usps.NewBulkProcessor(client, config)

    // Process all addresses with automatic rate limiting and retries
    results := processor.ProcessAddresses(context.Background(), addresses)

    // Handle results
    for _, result := range results {
        if result.Error != nil {
            log.Printf("Address %d failed: %v", result.Index, result.Error)
            continue
        }

        fmt.Printf("Standardized: %s, %s, %s %s\n",
            result.Response.Address.StreetAddress,
            result.Response.Address.City,
            result.Response.Address.State,
            result.Response.Address.ZIPCode)
    }
}

Key Features:

  • Automatic rate limiting - Respects USPS API limits to prevent 429 errors
  • Concurrent processing - Configurable worker pool for optimal throughput
  • Smart retries - Exponential backoff for transient failures (500, 503, 429)
  • Progress tracking - Optional callback for real-time progress monitoring
  • Context support - Full cancellation and timeout support

The bulk processor also supports ProcessCityStates() and ProcessZIPCodes() for bulk lookups of other endpoint types.

Auto-complete ZIP Codes

Help users by automatically filling in ZIP codes:

func AutoCompleteZIP(street, city, state string) (string, error) {
    // Note: In production, create the client once and reuse it
    client := usps.NewClientWithOAuth(clientID, clientSecret)

    req := &models.ZIPCodeRequest{
        StreetAddress: street,
        City:          city,
        State:         state,
    }

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

    // Return ZIP+4 format if available
    if resp.ZIPCode.ZIPPlus4 != nil && *resp.ZIPCode.ZIPPlus4 != "" {
        return fmt.Sprintf("%s-%s", resp.ZIPCode.ZIPCode, *resp.ZIPCode.ZIPPlus4), nil
    }

    return resp.ZIPCode.ZIPCode, nil
}

Verify Business Addresses

Check if an address is a business location:

func IsBusinessAddress(address *models.AddressRequest) (bool, error) {
    // Note: In production, create the client once and reuse it
    client := usps.NewClientWithOAuth(clientID, clientSecret)

    resp, err := client.GetAddress(context.Background(), address)
    if err != nil {
        return false, err
    }

    // Check additional info for business indicator
    if resp.AdditionalInfo != nil {
        return resp.AdditionalInfo.Business == "Y", nil
    }

    return false, nil
}

Format Addresses for Mailing

Standardize addresses for mail merge or label printing:

import (
    "context"
    "fmt"
    "strings"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

func FormatMailingLabel(address *models.AddressRequest) (string, error) {
    // Note: In production, create the client once and reuse it
    client := usps.NewClientWithOAuth(clientID, clientSecret)

    resp, err := client.GetAddress(context.Background(), address)
    if err != nil {
        return "", err
    }

    // Build formatted address
    var lines []string
    if resp.Firm != "" {
        lines = append(lines, resp.Firm)
    }
    lines = append(lines, resp.Address.StreetAddress)
    if resp.Address.SecondaryAddress != "" {
        lines = append(lines, resp.Address.SecondaryAddress)
    }

    cityLine := fmt.Sprintf("%s, %s %s",
        resp.Address.City,
        resp.Address.State,
        resp.Address.ZIPCode)

    if resp.Address.ZIPPlus4 != nil && *resp.Address.ZIPPlus4 != "" {
        cityLine = fmt.Sprintf("%s, %s %s-%s",
            resp.Address.City,
            resp.Address.State,
            resp.Address.ZIPCode,
            *resp.Address.ZIPPlus4)
    }

    lines = append(lines, cityLine)
    return strings.Join(lines, "\n"), nil
}

Address Parsing

The parser package provides intelligent parsing of free-form address strings into structured AddressRequest objects. This is essential for handling user input that doesn't follow a strict format.

Why Use the Parser?

  • Handle free-form input - Parse addresses from a single text field
  • Smart tokenization - Automatically identifies address components
  • USPS standardization - Applies official USPS abbreviations and formatting
  • Validation feedback - Provides diagnostics for missing or incorrect components
  • Zero dependencies - Pure Go implementation using only the standard library

Quick Example

import (
    "context"
    "fmt"
    "log"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/parser"
)

func main() {
    // Parse a free-form address string
    input := "123 North Main Street Apartment 4B, New York, NY 10001-1234"
    parsed, diagnostics := parser.Parse(input)

    // Check for issues
    for _, d := range diagnostics {
        fmt.Printf("%s: %s\n", d.Severity, d.Message)
    }

    // Convert to AddressRequest
    req := parsed.ToAddressRequest()

    fmt.Printf("Street: %s\n", req.StreetAddress)     // "123 N MAIN ST"
    fmt.Printf("Secondary: %s\n", req.SecondaryAddress) // "APT 4B"
    fmt.Printf("City: %s\n", req.City)                 // "NEW YORK"
    fmt.Printf("State: %s\n", req.State)               // "NY"
    fmt.Printf("ZIP: %s\n", req.ZIPCode)               // "10001"
    fmt.Printf("ZIP+4: %s\n", req.ZIPPlus4)            // "1234"
}

Integration with USPS API

Combine parsing with USPS validation for the complete workflow:

import (
    "context"
    "fmt"
    "log"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/parser"
)

func ValidateFreeFormAddress(userInput string) error {
    // Step 1: Parse the free-form input
    parsed, diagnostics := parser.Parse(userInput)

    // Step 2: Check for critical errors
    for _, d := range diagnostics {
        if d.Severity == parser.SeverityError {
            return fmt.Errorf("parse error: %s - %s", d.Message, d.Remediation)
        }
    }

    // Step 3: Convert to AddressRequest
    req := parsed.ToAddressRequest()

    // Step 4: Validate with USPS API
    client := usps.NewClientWithOAuth("client-id", "client-secret")
    resp, err := client.GetAddress(context.Background(), req)
    if err != nil {
        return fmt.Errorf("validation failed: %v", err)
    }

    fmt.Printf("Validated address: %s, %s, %s %s\n",
        resp.Address.StreetAddress,
        resp.Address.City,
        resp.Address.State,
        resp.Address.ZIPCode)

    return nil
}

Key Features

Intelligent Component Recognition:

// Handles directionals
parser.Parse("123 North Main St, New York, NY 10001")
// → Street: "123 N MAIN ST"

// Handles secondary units
parser.Parse("456 Oak Ave Apt 4B, Boston, MA 02101")
// → Street: "456 OAK AVE", Secondary: "APT 4B"

// Handles ZIP+4
parser.Parse("789 Elm Blvd, Chicago, IL 60601-1234")
// → ZIP: "60601", ZIP+4: "1234"

Automatic Standardization:

The parser applies USPS Publication 28 standards automatically:

Input Standardized
Street ST
Avenue AVE
Boulevard BLVD
North N
Apartment APT
Suite STE

Diagnostics and Validation:

parsed, diagnostics := parser.Parse("123 Main St, New York")

for _, d := range diagnostics {
    fmt.Printf("%s: %s\n", d.Severity, d.Message)
    if d.Remediation != "" {
        fmt.Printf("  Fix: %s\n", d.Remediation)
    }
}

// Output:
// Error: Missing required state code
//   Fix: Add a 2-letter state code (e.g., NY, CA, TX)
// Warning: Missing ZIP code
//   Fix: Add a 5-digit ZIP code for better address validation

Common Use Cases

Single-field address input:

// User enters complete address in one field
userInput := "123 Main St Apt 4, New York, NY 10001"
parsed, _ := parser.Parse(userInput)
req := parsed.ToAddressRequest()

// Now ready for USPS validation
resp, err := client.GetAddress(ctx, req)

Form auto-fill:

// Parse as user types, fill individual fields
parsed, _ := parser.Parse(userInput)

// Use ToAddressRequest() for proper formatting
req := parsed.ToAddressRequest()

streetField.SetText(req.StreetAddress)
secondaryField.SetText(req.SecondaryAddress)
cityField.SetText(req.City)
stateField.SetText(req.State)
zipField.SetText(req.ZIPCode)

Import from CSV or external data:

// Parse addresses from external sources
for _, row := range csvData {
    parsed, diagnostics := parser.Parse(row.AddressColumn)
    
    // Check for errors
    hasErrors := false
    for _, d := range diagnostics {
        if d.Severity == parser.SeverityError {
            hasErrors = true
            break
        }
    }
    
    if hasErrors {
        log.Printf("Skipping invalid address: %s", row.AddressColumn)
        continue
    }
    
    req := parsed.ToAddressRequest()
    // Validate with USPS...
}

For complete parser documentation, see the parser package README.


Advanced Usage

Custom Token Provider

Implement the TokenProvider interface for advanced authentication scenarios like credential rotation, vault integration, or custom caching:

import (
    "context"
    "fmt"

    vault "github.com/hashicorp/vault/api" // Example: HashiCorp Vault client
)

type TokenProvider interface {
    GetToken(ctx context.Context) (string, error)
}

// Example: Vault-backed token provider
type VaultTokenProvider struct {
    vaultClient *vault.Client
    path        string
}

func (p *VaultTokenProvider) GetToken(ctx context.Context) (string, error) {
    secret, err := p.vaultClient.Logical().Read(p.path)
    if err != nil {
        return "", err
    }

    token, ok := secret.Data["usps_token"].(string)
    if !ok {
        return "", fmt.Errorf("token not found in vault")
    }

    return token, nil
}

// Use with client
client := usps.NewClient(&VaultTokenProvider{
    vaultClient: vaultClient,
    path:        "secret/data/usps",
})

Custom HTTP Client

Configure timeouts, retries, and transport settings:

import (
    "net/http"
    "time"

    "github.com/my-eq/go-usps"
)

httpClient := &http.Client{
    Timeout: 60 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
        TLSHandshakeTimeout: 10 * time.Second,
    },
}

client := usps.NewClient(
    tokenProvider,
    usps.WithHTTPClient(httpClient),
)

Retry Logic with Exponential Backoff

Handle transient failures with intelligent retries:

import (
    "context"
    "fmt"
    "time"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

func GetAddressWithRetry(client *usps.Client, req *models.AddressRequest) (*models.AddressResponse, error) {
    maxRetries := 3
    baseDelay := 1 * time.Second

    for attempt := 0; attempt <= maxRetries; attempt++ {
        ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

        resp, err := client.GetAddress(ctx, req)
        cancel()
        if err == nil {
            return resp, nil
        }

        // Check if error is retryable
        if apiErr, ok := err.(*usps.APIError); ok {
            // Don't retry 4xx errors (except 429)
            if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 && apiErr.StatusCode != 429 {
                return nil, err
            }
        }

        if attempt < maxRetries {
            delay := baseDelay * time.Duration(1<<uint(attempt)) // Exponential backoff
            time.Sleep(delay)
        }
    }

    return nil, fmt.Errorf("max retries exceeded")
}

Circuit Breaker Pattern

Protect your application from cascading failures:

import (
    "context"
    "fmt"
    "sync"
    "time"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

type CircuitBreaker struct {
    client       *usps.Client
    maxFailures  int
    resetTimeout time.Duration

    mu            sync.Mutex
    failures      int
    lastFailTime  time.Time
    state         string // "closed", "open", "half-open"
}

func (cb *CircuitBreaker) GetAddress(ctx context.Context, req *models.AddressRequest) (*models.AddressResponse, error) {
    cb.mu.Lock()

    // Check if circuit should be reset
    if cb.state == "open" && time.Since(cb.lastFailTime) > cb.resetTimeout {
        cb.state = "half-open"
        cb.failures = 0
    }

    if cb.state == "open" {
        cb.mu.Unlock()
        return nil, fmt.Errorf("circuit breaker is open")
    }

    cb.mu.Unlock()

    // Attempt the request
    resp, err := cb.client.GetAddress(ctx, req)

    cb.mu.Lock()
    defer cb.mu.Unlock()

    if err != nil {
        cb.failures++
        cb.lastFailTime = time.Now()

        if cb.failures >= cb.maxFailures {
            cb.state = "open"
        }

        return nil, err
    }

    // Success - reset circuit
    cb.failures = 0
    cb.state = "closed"

    return resp, nil
}

Request Middleware

Add logging, metrics, or tracing to all requests:

import (
    "context"
    "log"
    "time"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

// Note: This example shows the pattern. MetricsCollector is a pseudo-code interface.
// In production, use a concrete metrics library like Prometheus (see Metrics Collection example).
type InstrumentedClient struct {
    client  *usps.Client
    logger  *log.Logger
    metrics MetricsCollector // Your metrics implementation
}

// MetricsCollector interface (implement this with your metrics library)
type MetricsCollector interface {
    RecordDuration(name string, duration time.Duration)
    IncrementCounter(name string)
}

func (ic *InstrumentedClient) GetAddress(ctx context.Context, req *models.AddressRequest) (*models.AddressResponse, error) {
    start := time.Now()

    ic.logger.Printf("GetAddress request: %s, %s, %s", req.StreetAddress, req.City, req.State)

    resp, err := ic.client.GetAddress(ctx, req)

    duration := time.Since(start)
    ic.metrics.RecordDuration("get_address", duration)

    if err != nil {
        ic.metrics.IncrementCounter("get_address_errors")
        ic.logger.Printf("GetAddress error: %v (duration: %v)", err, duration)
        return nil, err
    }

    ic.metrics.IncrementCounter("get_address_success")
    ic.logger.Printf("GetAddress success (duration: %v)", duration)

    return resp, nil
}

Manual OAuth Management

For complex OAuth flows like authorization code with PKCE:

// Step 1: Obtain authorization code (user redirects to USPS)
authURL := "https://apis.usps.com/oauth2/v3/authorize?" +
    "client_id=your-client-id&" +
    "redirect_uri=https://yourapp.com/callback&" +
    "response_type=code&" +
    "scope=addresses"

// Step 2: Exchange code for tokens
oauthClient := usps.NewOAuthClient()

req := &models.AuthorizationCodeCredentials{
    GrantType:    "authorization_code",
    ClientID:     "your-client-id",
    ClientSecret: "your-client-secret",
    Code:         codeFromCallback,
    RedirectURI:  "https://yourapp.com/callback",
}

result, err := oauthClient.PostToken(context.Background(), req)
if err != nil {
    return err
}

tokens := result.(*models.ProviderTokensResponse)

// Step 3: Use access token
tokenProvider := usps.NewStaticTokenProvider(tokens.AccessToken)
client := usps.NewClient(tokenProvider)

// Step 4: Refresh when needed
refreshReq := &models.RefreshTokenCredentials{
    GrantType:    "refresh_token",
    ClientID:     "your-client-id",
    ClientSecret: "your-client-secret",
    RefreshToken: tokens.RefreshToken,
}

newTokens, err := oauthClient.PostToken(context.Background(), refreshReq)

Testing with Mock Responses

Create a custom token provider for testing:

type MockTokenProvider struct{}

func (m *MockTokenProvider) GetToken(ctx context.Context) (string, error) {
    return "mock-token-for-testing", nil
}

// In tests
func TestAddressValidation(t *testing.T) {
    // Use test environment
    client := usps.NewTestClient(&MockTokenProvider{})

    // Your test code here
}

Advanced Topics

Distributed Systems Considerations

Service-to-Service Authentication

When running in a distributed environment, centralize OAuth token management:

// Token service that manages tokens for all microservices
type TokenService struct {
    client   *usps.OAuthClient
    clientID string
    secret   string

    mu    sync.RWMutex
    token string
    expiry time.Time
}

func (ts *TokenService) GetToken(ctx context.Context) (string, error) {
    ts.mu.RLock()
    if time.Now().Before(ts.expiry.Add(-5 * time.Minute)) {
        token := ts.token
        ts.mu.RUnlock()
        return token, nil
    }
    ts.mu.RUnlock()

    // Need to refresh
    ts.mu.Lock()
    defer ts.mu.Unlock()

    // Double-check after acquiring write lock
    if time.Now().Before(ts.expiry.Add(-5 * time.Minute)) {
        return ts.token, nil
    }

    // Fetch new token
    req := &models.ClientCredentials{
        GrantType:    "client_credentials",
        ClientID:     ts.clientID,
        ClientSecret: ts.secret,
    }

    result, err := ts.client.PostToken(ctx, req)
    if err != nil {
        return "", err
    }

    resp := result.(*models.ProviderAccessTokenResponse)
    ts.token = resp.AccessToken
    ts.expiry = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second)

    return ts.token, nil
}

Load Balancing and Failover

Distribute requests across multiple client instances:

import (
    "context"
    "sync/atomic"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

type LoadBalancedClient struct {
    clients []*usps.Client
    idx     uint32
}

func (lbc *LoadBalancedClient) GetAddress(ctx context.Context, req *models.AddressRequest) (*models.AddressResponse, error) {
    // Round-robin selection
    idx := atomic.AddUint32(&lbc.idx, 1)
    client := lbc.clients[idx%uint32(len(lbc.clients))]

    return client.GetAddress(ctx, req)
}

// Initialize with multiple clients
func NewLoadBalancedClient(tokenProvider usps.TokenProvider, count int) *LoadBalancedClient {
    clients := make([]*usps.Client, count)
    for i := 0; i < count; i++ {
        clients[i] = usps.NewClient(tokenProvider)
    }
    return &LoadBalancedClient{clients: clients}
}

Caching Strategies

In-Memory Cache with TTL

Cache validated addresses to reduce API calls:

type CachedClient struct {
    client *usps.Client
    cache  *sync.Map
    ttl    time.Duration
}

type cacheEntry struct {
    response  *models.AddressResponse
    timestamp time.Time
}

func (cc *CachedClient) GetAddress(ctx context.Context, req *models.AddressRequest) (*models.AddressResponse, error) {
    // Create cache key
    key := fmt.Sprintf("%s|%s|%s", req.StreetAddress, req.City, req.State)

    // Check cache
    if val, ok := cc.cache.Load(key); ok {
        entry := val.(cacheEntry)
        if time.Since(entry.timestamp) < cc.ttl {
            return entry.response, nil
        }
        cc.cache.Delete(key) // Expired
    }

    // Fetch from API
    resp, err := cc.client.GetAddress(ctx, req)
    if err != nil {
        return nil, err
    }

    // Store in cache
    cc.cache.Store(key, cacheEntry{
        response:  resp,
        timestamp: time.Now(),
    })

    return resp, nil
}

Redis-backed Cache

For distributed caching across multiple instances:

import (
    "context"
    "encoding/json"
    "time"

    "github.com/go-redis/redis/v8" // Example: go-redis client
    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

type RedisCachedClient struct {
    client      *usps.Client
    redisClient *redis.Client
    ttl         time.Duration
}

func (rc *RedisCachedClient) GetAddress(ctx context.Context, req *models.AddressRequest) (*models.AddressResponse, error) {
    key := fmt.Sprintf("usps:address:%s:%s:%s", req.StreetAddress, req.City, req.State)

    // Try cache first
    cached, err := rc.redisClient.Get(ctx, key).Result()
    if err == nil {
        var resp models.AddressResponse
        if json.Unmarshal([]byte(cached), &resp) == nil {
            return &resp, nil
        }
    }

    // Fetch from API
    resp, err := rc.client.GetAddress(ctx, req)
    if err != nil {
        return nil, err
    }

    // Store in Redis
    if data, err := json.Marshal(resp); err == nil {
        rc.redisClient.Set(ctx, key, data, rc.ttl)
    }

    return resp, nil
}

Rate Limiting

Built-in Bulk Operations (Recommended)

For most use cases, use the built-in BulkProcessor which includes automatic rate limiting:

import (
    "context"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

func ProcessWithRateLimit(addresses []*models.AddressRequest) {
    client := usps.NewClientWithOAuth(clientID, clientSecret)

    // Configure rate limiting
    config := &usps.BulkConfig{
        MaxConcurrency:    10,  // Concurrent workers
        RequestsPerSecond: 10,  // Automatic rate limiting
        MaxRetries:        3,   // Retry on rate limit errors
    }

    processor := usps.NewBulkProcessor(client, config)
    results := processor.ProcessAddresses(context.Background(), addresses)

    // Handle results...
}

The bulk processor uses a token bucket algorithm (stdlib only) to enforce rate limits and automatically handles 429 responses with exponential backoff.

Manual Rate Limiting (Advanced)

For custom implementations, you can build your own rate limiter:

import (
    "context"
    "fmt"

    "golang.org/x/time/rate"
    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

type RateLimitedClient struct {
    client      *usps.Client
    limiter     *rate.Limiter
}

func NewRateLimitedClient(client *usps.Client, requestsPerSecond int) *RateLimitedClient {
    return &RateLimitedClient{
        client:  client,
        limiter: rate.NewLimiter(rate.Limit(requestsPerSecond), requestsPerSecond),
    }
}

func (rlc *RateLimitedClient) GetAddress(ctx context.Context, req *models.AddressRequest) (*models.AddressResponse, error) {
    // Wait for rate limiter
    if err := rlc.limiter.Wait(ctx); err != nil {
        return nil, fmt.Errorf("rate limit: %w", err)
    }

    return rlc.client.GetAddress(ctx, req)
}

Observability

Structured Logging

Add comprehensive logging for production debugging:

import (
    "context"
    "fmt"
    "log/slog"
    "time"

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

type ObservableClient struct {
    client *usps.Client
    logger *slog.Logger
}

func (oc *ObservableClient) GetAddress(ctx context.Context, req *models.AddressRequest) (*models.AddressResponse, error) {
    requestID := ctx.Value("request_id")

    oc.logger.Info("address_validation_start",
        slog.String("request_id", fmt.Sprint(requestID)),
        slog.String("street", req.StreetAddress),
        slog.String("city", req.City),
        slog.String("state", req.State))

    start := time.Now()
    resp, err := oc.client.GetAddress(ctx, req)
    duration := time.Since(start)

    if err != nil {
        oc.logger.Error("address_validation_failed",
            slog.String("request_id", fmt.Sprint(requestID)),
            slog.Duration("duration", duration),
            slog.String("error", err.Error()))
        return nil, err
    }

    oc.logger.Info("address_validation_success",
        slog.String("request_id", fmt.Sprint(requestID)),
        slog.Duration("duration", duration),
        slog.String("zip", resp.Address.ZIPCode))

    return resp, nil
}

Metrics Collection

Track performance and errors with Prometheus:

import (
    "context"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

var (
    requestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name: "usps_request_duration_seconds",
            Help: "Duration of USPS API requests",
        },
        []string{"endpoint", "status"},
    )

    requestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "usps_requests_total",
            Help: "Total number of USPS API requests",
        },
        []string{"endpoint", "status"},
    )
)

func init() {
    prometheus.MustRegister(requestDuration)
    prometheus.MustRegister(requestsTotal)
}

type MetricsClient struct {
    client *usps.Client
}

func (mc *MetricsClient) GetAddress(ctx context.Context, req *models.AddressRequest) (*models.AddressResponse, error) {
    start := time.Now()

    resp, err := mc.client.GetAddress(ctx, req)

    duration := time.Since(start).Seconds()
    status := "success"
    if err != nil {
        status = "error"
    }

    requestDuration.WithLabelValues("get_address", status).Observe(duration)
    requestsTotal.WithLabelValues("get_address", status).Inc()

    return resp, err
}

Health Checks

Implement health checks for Kubernetes or load balancers:

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

    "github.com/my-eq/go-usps"
    "github.com/my-eq/go-usps/models"
)

func USPSHealthCheck(client *usps.Client) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
        defer cancel()

        // Use a known good address for health check
        req := &models.AddressRequest{
            StreetAddress: "475 L'Enfant Plaza SW",
            City:          "Washington",
            State:         "DC",
        }

        _, err := client.GetAddress(ctx, req)
        if err != nil {
            w.WriteHeader(http.StatusServiceUnavailable)
            json.NewEncoder(w).Encode(map[string]string{
                "status": "unhealthy",
                "error":  err.Error(),
            })
            return
        }

        w.WriteHeader(http.StatusOK)
        json.NewEncoder(w).Encode(map[string]string{
            "status": "healthy",
        })
    }
}

Production Checklist

When deploying to production, ensure you have:

  • ✓ Error handling - Graceful degradation for API failures
  • ✓ Timeouts - Context timeouts on all requests (5-10 seconds recommended)
  • ✓ Retries - Exponential backoff for transient failures
  • ✓ Rate limiting - Respect USPS API limits to avoid 429 errors
  • ✓ Caching - Cache validated addresses (TTL: 24 hours recommended)
  • ✓ Monitoring - Track success rates, latencies, and error rates
  • ✓ Circuit breaker - Prevent cascading failures
  • ✓ Logging - Structured logs with request IDs
  • ✓ Health checks - Endpoint for load balancer health checks
  • ✓ Token rotation - Automatic OAuth token refresh
  • ✓ Secrets management - Never hardcode credentials

API Reference

Response Fields

AddressResponse

type AddressResponse struct {
    Firm           string                 // Business name
    Address        *DomesticAddress       // Standardized address
    AdditionalInfo *AddressAdditionalInfo // Delivery point, carrier route, etc.
    Corrections    []AddressCorrection    // Suggested improvements
    Matches        []AddressMatch         // Match indicators
    Warnings       []string               // Warnings
}

DomesticAddress

type DomesticAddress struct {
    StreetAddress    string  // Standardized street address
    SecondaryAddress string  // Apartment, suite, etc.
    City             string  // City name
    State            string  // 2-letter state code
    ZIPCode          string  // 5-digit ZIP code
    ZIPPlus4         *string // 4-digit ZIP+4 extension (pointer, may be nil)
    Urbanization     string  // Urbanization code (Puerto Rico)
}

AddressAdditionalInfo

Rich delivery metadata returned with validated addresses:

type AddressAdditionalInfo struct {
    DeliveryPoint         string // Unique delivery address identifier (2 digits)
    CarrierRoute          string // Carrier route code (4 characters)
    DPVConfirmation       string // Delivery Point Validation: Y, D, S, or N
    DPVCMRA              string // Commercial Mail Receiving Agency: Y or N
    Business             string // Business address indicator: Y or N
    CentralDeliveryPoint string // Central delivery point: Y or N
    Vacant               string // Vacant address indicator: Y or N
}

DPV Confirmation Codes:

  • Y - Address is deliverable
  • D - Address is deliverable but missing secondary (apt, suite)
  • S - Address is deliverable to building, but not to specific unit
  • N - Address is not deliverable

AddressCorrection

The Corrections field provides visibility into all modifications the USPS API made to standardize your submitted address according to USPS Publication 28 (Postal Addressing Standards). This is crucial for understanding what changed and why.

type AddressCorrection struct {
    Code string // Correction type code
    Text string // Human-readable explanation
}

Common Correction Types:

The USPS API applies various standardizations to ensure addresses are deliverable:

  • Street Abbreviations - "Street" → "St", "Avenue" → "Ave", "Boulevard" → "Blvd"
  • Directional Standardization - "North" → "N", "Southwest" → "SW"
  • Unit Designators - "Apartment" → "Apt", "Suite" → "Ste", "Building" → "Bldg"
  • City Names - Full city names may be standardized or abbreviated per USPS rules
  • ZIP+4 Addition - Missing ZIP+4 codes are added when available
  • Secondary Address - Apartment/suite numbers may be reformatted or corrected
  • Spelling Corrections - Typos in street names or cities are automatically fixed

Example Corrections Array:

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

// Check what corrections were made
for _, correction := range resp.Corrections {
    fmt.Printf("Correction: %s - %s\n", correction.Code, correction.Text)
}

// Example output:
// Correction: st - Street was abbreviated to St
// Correction: zip4 - ZIP+4 code was added

Why Corrections Matter:

  1. Audit Trail - Track exactly how user input was modified
  2. User Feedback - Inform users about changes to improve future submissions
  3. Data Quality - Identify patterns in address entry issues
  4. Compliance - Ensure addresses meet USPS standards for optimal delivery

The corrections array helps you understand the transformation from submitted address to the final standardized USPS format, which is essential for deliverability and reducing returns.

Configuration Options

Client Options

// Custom timeout
client := usps.NewClient(tokenProvider, usps.WithTimeout(60 * time.Second))

// Custom HTTP client
client := usps.NewClient(tokenProvider, usps.WithHTTPClient(httpClient))

// Custom base URL (usually for testing)
client := usps.NewClient(tokenProvider, usps.WithBaseURL("https://custom.url"))

OAuth Provider Options

// Custom scopes
provider := usps.NewOAuthTokenProvider(
    clientID,
    clientSecret,
    usps.WithOAuthScopes("addresses tracking labels"),
)

// Custom refresh buffer (default: 5 minutes)
provider := usps.NewOAuthTokenProvider(
    clientID,
    clientSecret,
    usps.WithTokenRefreshBuffer(10 * time.Minute),
)

// Enable refresh tokens
provider := usps.NewOAuthTokenProvider(
    clientID,
    clientSecret,
    usps.WithRefreshTokens(true),
)

// Testing environment
provider := usps.NewOAuthTokenProvider(
    clientID,
    clientSecret,
    usps.WithOAuthEnvironment("testing"),
)

Error Types

APIError

Returned for USPS API errors (4xx, 5xx responses):

type APIError struct {
    StatusCode   int
    ErrorMessage *ErrorMessage
}

func (e *APIError) Error() string {
    if e.ErrorMessage != nil && e.ErrorMessage.Error != nil {
        return e.ErrorMessage.Error.Message
    }
    return fmt.Sprintf("API error (status %d)", e.StatusCode)
}

OAuthError

Returned for OAuth authentication errors:

type OAuthError struct {
    StatusCode   int
    ErrorMessage *OAuthErrorMessage
}

Common OAuth error codes:

  • invalid_client - Invalid client credentials
  • invalid_grant - Invalid authorization code or refresh token
  • invalid_request - Malformed request
  • unauthorized_client - Client not authorized for grant type
  • unsupported_grant_type - Grant type not supported

Additional Resources

Official Documentation

Go Package Documentation

Getting Help


Development

Running Tests

# Run all tests
go test ./...

# Run with coverage
go test -cover ./...

# Run with race detection
go test -race ./...

Integration Tests

Integration tests require valid USPS credentials:

export USPS_CLIENT_ID="your-client-id"
export USPS_CLIENT_SECRET="your-client-secret"
go test -v ./... -tags=integration

Linting

# Go linting
go vet ./...
gofmt -l .

# Markdown linting
npx markdownlint-cli2 "**/*.md"

Requirements

  • Go 1.19+ - Uses standard library features from Go 1.19
  • USPS API Credentials - Obtain from USPS Developer Portal

API Documentation

For complete API documentation, see:

License

MIT License - See LICENSE file for details.


Contributing

Contributions are welcome! Please:

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Make your changes with tests
  4. Run linting and tests (go test ./... && go vet ./...)
  5. Commit your changes (git commit -m 'Add amazing feature')
  6. Push to the branch (git push origin feature/amazing-feature)
  7. Open a Pull Request

Please ensure your PR:

  • ✓ Includes tests for new functionality
  • ✓ Maintains or improves test coverage
  • ✓ Follows Go best practices and idiomatic style
  • ✓ Updates documentation as needed
  • ✓ Passes all CI checks

About

A lightweight Golang client for the USPS API

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •  

Languages