towerops/docs/exception_handling.md

210 lines
6.5 KiB
Markdown

# Exception Handling Strategy
## Overview
Towerops implements the `Plug.Exception` protocol for expected client errors to reduce noise in error tracking systems and provide clean HTTP responses for invalid requests.
## Problem Statement
Production Phoenix applications receive many "exceptions" that aren't actually application failures:
1. **API clients** sending incorrect `Accept` headers (triggers `Phoenix.NotAcceptableError`)
2. **Mobile apps** with stale CSRF tokens after backgrounding (triggers `InvalidCSRFTokenError`)
3. **Automation tools** (Terraform, scripts) with malformed requests
These are legitimate **client errors (400-level)**, not server failures (500-level). Without proper handling:
- Exception tracking services (Sentry, Rollbar, etc.) send alerts for every bad request
- Logs are cluttered with stack traces for expected behavior
- Difficult to identify genuine application bugs in the noise
## Solution
Implement `Plug.Exception` protocol for expected exceptions to convert them into clean HTTP 400 responses.
### Implementation
Located in: `lib/towerops_web/plug_exceptions.ex`
```elixir
defimpl Plug.Exception, for: Phoenix.NotAcceptableError do
def status(_exception), do: 400
def actions(_exception), do: []
end
defimpl Plug.Exception, for: Plug.CSRFProtection.InvalidCSRFTokenError do
def status(_exception), do: 400
def actions(_exception), do: []
end
```
### How It Works
1. **Before**: Exception raised → 500 error → stack trace logged → alert sent
2. **After**: Exception raised → protocol converts to 400 → debug log only → no alert
Phoenix checks if an exception implements `Plug.Exception` protocol:
- If yes: calls `status/1` to get HTTP status code, returns that status
- If no: treats as 500 Internal Server Error, logs stack trace
## Covered Exception Types
### Phoenix.NotAcceptableError
**Triggered when**: Client sends `Accept` header that doesn't match controller format.
**Common scenarios**:
- API client requesting `Accept: application/xml` when only JSON is supported
- Automation tools with default headers hitting JSON endpoints
- Browser requests to API-only endpoints
**Example**:
```bash
curl -H "Accept: application/xml" https://towerops.net/api/v1/sites
# Returns: 400 Bad Request (not 500)
```
### Plug.CSRFProtection.InvalidCSRFTokenError
**Triggered when**: CSRF token is missing, invalid, or expired.
**Common scenarios**:
- Mobile app resumed after hours of inactivity (token expired)
- User left browser tab open overnight (session invalidated)
- Legitimate request replayed after logout/login cycle
**Example**:
- User opens form, leaves for lunch, returns and submits → 400 (not 500)
- iOS app backgrounded for hours, user returns and taps button → 400
## Monitoring & Security
### Debug Logging
Both exception types log at debug level before converting:
```elixir
Logger.debug("Converting Phoenix.NotAcceptableError to 400: #{inspect(exception)}")
```
This allows:
- Visibility in development (`mix phx.server` shows debug logs)
- Optional monitoring in production (enable debug logs temporarily)
- No production alerts for expected behavior
### Security Considerations
**CSRF Protection Still Active**: Converting to 400 doesn't disable CSRF protection:
- Invalid tokens still rejected (request fails)
- User sees "Bad Request" instead of generic error page
- Audit logs still capture failed requests
**Attack Detection**: Excessive CSRF errors may indicate:
- Automated CSRF attack attempts
- Client implementation bugs
- Session fixation attempts
**Recommended**: Set up monitoring for 400 error rate spikes on protected endpoints.
### What We're NOT Doing
This pattern should **only** apply to expected client errors:
**Don't convert**:
- Database connection errors (500)
- Ecto query errors (500)
- Permission violations (403, handled separately)
- Authentication failures (401, handled separately)
- Not found errors (404, handled separately)
**Do convert**:
- Malformed request format (400)
- Invalid content negotiation (400)
- Stale CSRF tokens (400)
## Testing
Tests verify both the protocol implementation and actual HTTP behavior:
```elixir
# Protocol implementation
test "implements status/1 callback" do
exception = %Phoenix.NotAcceptableError{}
assert Plug.Exception.status(exception) == 400
end
# Actual HTTP behavior
test "returns 400 for invalid Accept header" do
conn =
build_conn()
|> put_req_header("accept", "application/invalid")
|> get("/api/v1/sites")
assert conn.status == 400
end
```
See: `test/towerops_web/plug_exceptions_test.exs`
## Adding New Exception Types
When adding new exception types, follow this checklist:
1. **Verify it's a client error**:
- Is this caused by invalid user input or client behavior?
- Would a well-behaved client avoid this error?
- Is this a 400-level status code scenario?
2. **Add protocol implementation** in `plug_exceptions.ex`:
```elixir
defimpl Plug.Exception, for: YourException do
def status(_exception), do: 400
def actions(_exception), do: []
end
```
3. **Add tests** in `plug_exceptions_test.exs`:
- Test protocol callbacks (`status/1`, `actions/1`)
- Test actual HTTP behavior (integration test)
4. **Update documentation**:
- Add to "Covered Exception Types" section above
- Document common scenarios that trigger it
- Add example request/response
5. **Monitor in production**:
- Watch error rates for first week after deployment
- Verify no legitimate errors are being masked
- Adjust if needed
## Production Rollout
When deploying this pattern:
1. **Before deployment**:
- Review exception tracking service (Sentry, Rollbar, etc.)
- Note baseline error rates for each exception type
- Set up 400 status code monitoring (separate from alerts)
2. **After deployment**:
- Verify exception alerts decreased for covered types
- Check 400 error rates in metrics (should replace 500s)
- Spot-check logs to ensure legitimate errors still visible
3. **Long-term**:
- Review 400 error patterns quarterly
- Consider adding new exception types if patterns emerge
- Remove if causing issues (easily reversible)
## References
- **Original Article**: [Handle Phoenix Exceptions Gracefully](https://peterullrich.com/handle-phoenix-exceptions-gracefully)
- **Plug.Exception Protocol**: https://hexdocs.pm/plug/Plug.Exception.html
- **Phoenix Error Handling**: https://hexdocs.pm/phoenix/errors.html
## Maintenance Log
| Date | Change | Reason |
|------|--------|--------|
| 2026-02-04 | Initial implementation | Reduce error tracking noise from API clients and stale CSRF tokens |