Async address validation microservice with background processing
Production-ready FastAPI service for validating shipping addresses via ShipEngine API. Features async PostgreSQL storage, Redis-backed task queue (ARQ), and comprehensive validation with address normalization.
- Features
- Tech Stack
- Prerequisites
- Quick Start
- Development
- API Reference
- Architecture
- Configuration
- Testing
- Docker
- Project Structure
- Troubleshooting
- Contributing
- License
- Async-first — Built on FastAPI with full async/await support
- Background validation — Non-blocking address validation via ARQ workers
- Address normalization — Automatic formatting of streets, cities, postal codes
- Validation status tracking — PENDING → VERIFIED/WARNING/ERROR states
- Pagination — Efficient listing with limit/offset
- Health checks — Liveness and readiness probes for Kubernetes
- Type-safe — Full type hints with Pydantic v2 validation
- Production-ready — Docker multi-stage builds, proper error handling
| Component | Technology | Version |
|---|---|---|
| Web Framework | FastAPI | 0.115+ |
| ORM | SQLAlchemy (async) | 2.0+ |
| Database | PostgreSQL | 16+ |
| DB Driver | asyncpg | 0.30+ |
| Task Queue | ARQ | 0.26+ |
| Cache/Queue | Redis | 7+ |
| Validation | Pydantic | 2.10+ |
| Migrations | Alembic | 1.14+ |
Before you begin, ensure you have the following installed:
- Python 3.12+ — Download
- Docker & Docker Compose — Install Docker
- uv (recommended) — Fast Python package manager
curl -LsSf https://astral.sh/uv/install.sh | sh
# Clone the repository
git clone https://github.com/user/shipengine-validation.git
cd shipengine-validation
# Copy environment file
cp .env.example .env
# Start all services
docker compose up -d
# Run database migrations
docker compose exec app alembic upgrade head
# Verify installation
curl http://localhost:8000/api/v1/healthExpected response:
{"status": "ok"}Once running, access the interactive docs:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
# Install dependencies
uv sync --all-extras
# Start PostgreSQL and Redis (via Docker)
docker compose up -d db redis
# Run migrations
uv run alembic upgrade head
# Start the API server
uv run uvicorn src.main:app --reload
# In a separate terminal, start the worker
uv run arq src.workers.settings.WorkerSettings# Lint code
uv run ruff check src tests
# Format code
uv run ruff format src tests
# Type checking
uv run mypy src
# Run all checks
uv run ruff check src tests && uv run ruff format --check src tests && uv run mypy srcBase URL: http://localhost:8000/api/v1
| Method | Endpoint | Description |
|---|---|---|
POST |
/addresses |
Create address + enqueue validation |
GET |
/addresses |
List addresses (paginated) |
GET |
/addresses/{id} |
Get address with validation results |
PUT |
/addresses/{id} |
Update address + re-validate |
DELETE |
/addresses/{id} |
Delete address |
POST |
/addresses/{id}/validate |
Trigger re-validation |
GET |
/health |
Liveness check |
GET |
/health/ready |
Readiness check (includes DB) |
curl -X POST http://localhost:8000/api/v1/addresses \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"company_name": "Acme Corp",
"address_line1": "123 Main Street",
"city_locality": "Austin",
"state_province": "TX",
"postal_code": "78701",
"country_code": "US"
}'Response (201 Created):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe",
"company_name": "Acme Corp",
"address_line1": "123 Main Street",
"city_locality": "Austin",
"state_province": "TX",
"postal_code": "78701",
"country_code": "US",
"validation_status": "pending",
"validated_at": null,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": null,
"validation_results": []
}# Get first 10 addresses
curl "http://localhost:8000/api/v1/addresses?limit=10&offset=0"Response (200 OK):
{
"items": [...],
"total": 42,
"limit": 10,
"offset": 0
}curl http://localhost:8000/api/v1/addresses/550e8400-e29b-41d4-a716-446655440000Response (200 OK):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"validation_status": "verified",
"validated_at": "2024-01-15T10:30:05Z",
"validation_results": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"status": "verified",
"matched_address": {
"address_line1": "123 MAIN ST",
"city_locality": "AUSTIN",
"state_province": "TX",
"postal_code": "78701",
"country_code": "US"
},
"messages": [],
"created_at": "2024-01-15T10:30:05Z"
}
]
}curl -X PUT http://localhost:8000/api/v1/addresses/550e8400-e29b-41d4-a716-446655440000 \
-H "Content-Type: application/json" \
-d '{
"address_line1": "456 Oak Avenue",
"city_locality": "Austin",
"state_province": "TX",
"postal_code": "78702",
"country_code": "US"
}'Note: Updating an address resets
validation_statustopendingand enqueues a new validation task.
curl -X DELETE http://localhost:8000/api/v1/addresses/550e8400-e29b-41d4-a716-446655440000Response: 204 No Content
curl -X POST http://localhost:8000/api/v1/addresses/550e8400-e29b-41d4-a716-446655440000/validateResponse (200 OK):
{
"message": "Validation task enqueued"
}| Status | Code | Description |
|---|---|---|
400 |
BAD_REQUEST |
Invalid request body |
404 |
NOT_FOUND |
Address not found |
422 |
VALIDATION_ERROR |
Pydantic validation failed |
500 |
INTERNAL_ERROR |
Unexpected server error |
Error Response Format:
{
"detail": "Address with id=550e8400-... not found"
}flowchart LR
subgraph Client
A[HTTP Client]
end
subgraph API["FastAPI Application"]
B[Routes]
C[Services]
D[Repositories]
end
subgraph Storage
E[(PostgreSQL)]
F[(Redis)]
end
subgraph Background
G[ARQ Worker]
H[ShipEngine Client]
end
A -->|REST| B
B --> C
C --> D
D --> E
C -->|Enqueue| F
F -->|Dequeue| G
G --> H
G --> D
- Client sends
POST /api/v1/addresseswith address data - Service saves address with
validation_status=PENDING - Service enqueues validation task to Redis via ARQ
- API returns
201 Createdimmediately (non-blocking) - ARQ Worker picks up the task and validates via ShipEngine
- Worker saves
ValidationResultand updates address status - Client polls
GET /api/v1/addresses/{id}for updated status
PENDING → VERIFIED (valid address)
PENDING → WARNING (valid but has issues, e.g., PO Box)
PENDING → ERROR (invalid address)
PENDING → FAILED (validation service error)
| Variable | Default | Description |
|---|---|---|
APP_ENV |
local |
Environment: local, staging, production |
DEBUG |
true |
Enable debug mode (Swagger UI) |
POSTGRES_HOST |
localhost |
PostgreSQL host |
POSTGRES_PORT |
5432 |
PostgreSQL port |
POSTGRES_USER |
app |
PostgreSQL user |
POSTGRES_PASSWORD |
secret |
PostgreSQL password |
POSTGRES_DB |
shipengine |
PostgreSQL database name |
REDIS_URL |
redis://localhost:6379/0 |
Redis connection URL |
APP_ENV=local
DEBUG=true
# Database
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=app
POSTGRES_PASSWORD=secret
POSTGRES_DB=shipengine
# Redis
REDIS_URL=redis://localhost:6379/0For production deployments:
APP_ENV=production
DEBUG=false
POSTGRES_PASSWORD=<secure-password>uv run pytestuv run pytest --cov=src --cov-report=term-missing --cov-report=htmlCoverage report will be generated in htmlcov/index.html.
# Unit tests only
uv run pytest tests/unit -v
# Integration tests only
uv run pytest tests/integration -v
# Specific test file
uv run pytest tests/unit/test_address_service.py -v
# Specific test
uv run pytest tests/unit/test_address_service.py::test_create_address -vtests/
├── conftest.py # Shared fixtures
├── constants.py # Test constants
├── factories/ # Test data factories (Polyfactory)
├── unit/ # Unit tests (mocked dependencies)
│ ├── test_address_service.py
│ ├── test_shipengine_client.py
│ └── test_workers.py
└── integration/ # API tests (SQLite in-memory)
├── conftest.py
└── test_addresses_api.py
docker compose up -d# All services
docker compose logs -f
# Specific services
docker compose logs -f app workerdocker compose exec app alembic upgrade headdocker compose up --build -d# Stop containers
docker compose down
# Stop and remove volumes
docker compose down -v| Service | Port | Description |
|---|---|---|
app |
8000 | FastAPI application |
worker |
— | ARQ background worker |
db |
5432 | PostgreSQL database |
redis |
6379 | Redis (task queue) |
.
├── src/
│ ├── main.py # FastAPI app + lifespan
│ ├── config.py # Pydantic settings
│ ├── core/
│ │ ├── enums.py # ValidationStatus enum
│ │ └── exceptions.py # Domain exceptions
│ ├── db/
│ │ ├── models/ # SQLAlchemy models
│ │ │ ├── base.py
│ │ │ └── address.py
│ │ └── session.py # Async session factory
│ ├── api/
│ │ ├── dependencies/ # DI: get_db, get_service
│ │ └── routes/ # API endpoints
│ │ ├── addresses.py
│ │ └── health.py
│ ├── schemas/ # Pydantic request/response
│ │ ├── address.py
│ │ └── common.py
│ ├── repositories/ # Data access layer
│ │ ├── base.py # Generic repository
│ │ └── address_repository.py
│ ├── services/ # Business logic
│ │ ├── address_service.py
│ │ └── shipengine_client.py
│ └── workers/ # Background tasks
│ ├── tasks.py
│ └── settings.py
├── tests/ # Test suite
├── alembic/ # Database migrations
│ └── versions/
├── docker-compose.yml
├── Dockerfile
├── pyproject.toml
├── .env.example
└── README.md
sqlalchemy.exc.OperationalError: connection refused
Solution: Ensure PostgreSQL is running:
docker compose up -d db
# Wait for health check
docker compose psredis.exceptions.ConnectionError: Error connecting to localhost:6379
Solution: Start Redis:
docker compose up -d redissqlalchemy.exc.ProgrammingError: relation "addresses" does not exist
Solution: Run migrations:
docker compose exec app alembic upgrade head
# or locally
uv run alembic upgrade headSolution: Ensure worker is running:
docker compose logs worker
# or start manually
uv run arq src.workers.settings.WorkerSettingserror: [Errno 48] Address already in use
Solution: Kill the process using the port:
lsof -ti:8000 | xargs kill -9Enable detailed logging:
DEBUG=trueCheck application logs:
docker compose logs -f appContributions are welcome! Please follow these steps:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make your changes
- Run tests (
uv run pytest) - Run linting (
uv run ruff check src tests) - Commit your changes (
git commit -m 'feat: add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
We use Conventional Commits:
feat:— New featurefix:— Bug fixdocs:— Documentationrefactor:— Code refactoringtest:— Testschore:— Maintenance
- Follow PEP 8
- Use type hints for all functions
- Maximum line length: 88 characters (ruff default)
- Run
ruff formatbefore committing
This project is licensed under the MIT License — see the LICENSE file for details.
Made with FastAPI and async Python