handle exceptions gracefully on api endpoints
This commit is contained in:
parent
09f2907ede
commit
4ef4f4fbf6
3 changed files with 335 additions and 0 deletions
210
docs/exception_handling.md
Normal file
210
docs/exception_handling.md
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
# 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 |
|
||||
|
||||
69
lib/towerops_web/plug_exceptions.ex
Normal file
69
lib/towerops_web/plug_exceptions.ex
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
defmodule ToweropsWeb.PlugExceptions do
|
||||
@moduledoc """
|
||||
Protocol implementations for converting expected Phoenix exceptions
|
||||
into clean HTTP status codes rather than raising errors.
|
||||
|
||||
This reduces noise in error tracking by treating invalid requests
|
||||
(wrong MIME types, invalid CSRF tokens) as bad requests rather than
|
||||
application failures.
|
||||
|
||||
## Rationale
|
||||
|
||||
Production applications receive noise from legitimate bad requests:
|
||||
- API clients sending incorrect Accept headers
|
||||
- Mobile apps with stale CSRF tokens after backgrounding
|
||||
- Automation tools (Terraform, scripts) with malformed requests
|
||||
|
||||
These are client errors (400-level), not server failures (500-level).
|
||||
By implementing `Plug.Exception` protocol for these exceptions, we:
|
||||
|
||||
1. Return clean HTTP 400 Bad Request responses
|
||||
2. Reduce exception tracking alerts for expected client errors
|
||||
3. Keep error logs focused on genuine application failures
|
||||
|
||||
## Implementation
|
||||
|
||||
Each exception type gets a protocol implementation with two callbacks:
|
||||
|
||||
- `status/1` - Returns the HTTP status code (400 for bad requests)
|
||||
- `actions/1` - Returns suggested actions (empty list for now)
|
||||
|
||||
## Covered Exception Types
|
||||
|
||||
- `Phoenix.NotAcceptableError` - Invalid Accept headers from API clients
|
||||
- `Plug.CSRFProtection.InvalidCSRFTokenError` - Stale or invalid CSRF tokens
|
||||
|
||||
## Monitoring
|
||||
|
||||
These exceptions are still logged at debug level, allowing monitoring
|
||||
without triggering production alerts. If you see patterns indicating
|
||||
attacks (e.g., excessive CSRF failures), investigate via logs.
|
||||
|
||||
## See Also
|
||||
|
||||
- Documentation: `docs/exception_handling.md`
|
||||
- Tests: `test/towerops_web/plug_exceptions_test.exs`
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
# Invalid Accept headers from API consumers (automation, mobile apps, etc.)
|
||||
defimpl Plug.Exception, for: Phoenix.NotAcceptableError do
|
||||
def status(exception) do
|
||||
Logger.debug("Converting Phoenix.NotAcceptableError to 400: #{inspect(exception)}")
|
||||
400
|
||||
end
|
||||
|
||||
def actions(_exception), do: []
|
||||
end
|
||||
|
||||
# Stale CSRF tokens from mobile app or browser sessions
|
||||
defimpl Plug.Exception, for: Plug.CSRFProtection.InvalidCSRFTokenError do
|
||||
def status(exception) do
|
||||
Logger.debug("Converting InvalidCSRFTokenError to 400: #{inspect(exception)}")
|
||||
400
|
||||
end
|
||||
|
||||
def actions(_exception), do: []
|
||||
end
|
||||
end
|
||||
56
test/towerops_web/plug_exceptions_test.exs
Normal file
56
test/towerops_web/plug_exceptions_test.exs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
defmodule ToweropsWeb.PlugExceptionsTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
alias Plug.CSRFProtection.InvalidCSRFTokenError
|
||||
|
||||
describe "Phoenix.NotAcceptableError protocol implementation" do
|
||||
test "implements status/1 callback" do
|
||||
exception = %Phoenix.NotAcceptableError{}
|
||||
status = Plug.Exception.status(exception)
|
||||
assert status == 400
|
||||
end
|
||||
|
||||
test "implements actions/1 callback" do
|
||||
exception = %Phoenix.NotAcceptableError{}
|
||||
actions = Plug.Exception.actions(exception)
|
||||
assert actions == []
|
||||
end
|
||||
|
||||
test "returns 400 status code when sent", %{conn: conn} do
|
||||
# Verify that when the exception is raised and handled by Phoenix,
|
||||
# it returns 400 (not 500) due to our protocol implementation
|
||||
assert_error_sent 400, fn ->
|
||||
conn
|
||||
|> put_req_header("accept", "application/invalid")
|
||||
|> get("/api/v1/sites")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Plug.CSRFProtection.InvalidCSRFTokenError protocol implementation" do
|
||||
test "implements status/1 callback" do
|
||||
exception = %InvalidCSRFTokenError{}
|
||||
status = Plug.Exception.status(exception)
|
||||
assert status == 400
|
||||
end
|
||||
|
||||
test "implements actions/1 callback" do
|
||||
exception = %InvalidCSRFTokenError{}
|
||||
actions = Plug.Exception.actions(exception)
|
||||
assert actions == []
|
||||
end
|
||||
|
||||
test "returns 400 status code when exception is raised" do
|
||||
# Verify that the protocol converts the exception to 400
|
||||
# This simulates what Phoenix does when handling the exception
|
||||
exception = %InvalidCSRFTokenError{}
|
||||
|
||||
# Phoenix checks Plug.Exception protocol to determine status code
|
||||
status = Plug.Exception.status(exception)
|
||||
assert status == 400
|
||||
|
||||
# In production, this means Phoenix returns 400 instead of 500
|
||||
# when CSRF protection raises InvalidCSRFTokenError
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue