210 lines
6.5 KiB
Markdown
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 |
|
|
|