6.5 KiB
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:
- API clients sending incorrect
Acceptheaders (triggersPhoenix.NotAcceptableError) - Mobile apps with stale CSRF tokens after backgrounding (triggers
InvalidCSRFTokenError) - 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
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
- Before: Exception raised → 500 error → stack trace logged → alert sent
- 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/1to 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/xmlwhen only JSON is supported - Automation tools with default headers hitting JSON endpoints
- Browser requests to API-only endpoints
Example:
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:
Logger.debug("Converting Phoenix.NotAcceptableError to 400: #{inspect(exception)}")
This allows:
- Visibility in development (
mix phx.servershows 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:
# 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:
-
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?
-
Add protocol implementation in
plug_exceptions.ex:defimpl Plug.Exception, for: YourException do def status(_exception), do: 400 def actions(_exception), do: [] end -
Add tests in
plug_exceptions_test.exs:- Test protocol callbacks (
status/1,actions/1) - Test actual HTTP behavior (integration test)
- Test protocol callbacks (
-
Update documentation:
- Add to "Covered Exception Types" section above
- Document common scenarios that trigger it
- Add example request/response
-
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:
-
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)
-
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
-
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
- 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 |