156 lines
4.9 KiB
Markdown
156 lines
4.9 KiB
Markdown
# FastAPI Trailing Slash Issue - 307 Redirect & 403 Forbidden
|
|
|
|
## Problem Description
|
|
|
|
When making authenticated API calls to FastAPI endpoints, a mismatch in trailing slashes between the frontend request and backend route definition causes a **307 Temporary Redirect** that **loses the authentication header**, resulting in a **403 Forbidden** error.
|
|
|
|
## Root Cause
|
|
|
|
FastAPI automatically redirects requests to add or remove trailing slashes to match the route definition:
|
|
- If route is defined as `@router.get("/tasks/")` (with slash) and you call `/tasks` (without slash) → 307 redirect to `/tasks/`
|
|
- If route is defined as `@router.get("/tasks")` (without slash) and you call `/tasks/` (with slash) → 307 redirect to `/tasks`
|
|
|
|
**The problem:** HTTP redirects (307) do NOT preserve the `Authorization` header by default, so the redirected request arrives without authentication, causing a 403 Forbidden error.
|
|
|
|
## Symptoms
|
|
|
|
### Backend Logs
|
|
```
|
|
INFO: 127.0.0.1:59653 - "GET /tasks?shot_id=12 HTTP/1.1" 307 Temporary Redirect
|
|
INFO: 127.0.0.1:59615 - "GET /tasks/?shot_id=12 HTTP/1.1" 403 Forbidden
|
|
```
|
|
|
|
### Frontend Console
|
|
```
|
|
GET http://localhost:8000/tasks/?shot_id=12 403 (Forbidden)
|
|
AxiosError {message: 'Request failed with status code 403', ...}
|
|
```
|
|
|
|
## Solution
|
|
|
|
**Always ensure trailing slashes match between frontend API calls and backend route definitions.**
|
|
|
|
### Option 1: Add Trailing Slash to Frontend (Recommended)
|
|
|
|
**Frontend Service:**
|
|
```typescript
|
|
// ❌ WRONG - No trailing slash
|
|
const response = await apiClient.get(`/tasks?${params}`)
|
|
|
|
// ✅ CORRECT - With trailing slash
|
|
const response = await apiClient.get(`/tasks/?${params}`)
|
|
```
|
|
|
|
**Backend Route:**
|
|
```python
|
|
# Route defined WITH trailing slash
|
|
@router.get("/tasks/")
|
|
async def get_tasks(...):
|
|
...
|
|
```
|
|
|
|
### Option 2: Remove Trailing Slash from Backend
|
|
|
|
**Backend Route:**
|
|
```python
|
|
# Route defined WITHOUT trailing slash
|
|
@router.get("/tasks")
|
|
async def get_tasks(...):
|
|
...
|
|
```
|
|
|
|
**Frontend Service:**
|
|
```typescript
|
|
// Call WITHOUT trailing slash
|
|
const response = await apiClient.get(`/tasks?${params}`)
|
|
```
|
|
|
|
## Prevention Checklist
|
|
|
|
When adding or modifying routes, **always check**:
|
|
|
|
1. **Backend Route Definition** - Does it have a trailing slash?
|
|
```python
|
|
@router.get("/endpoint/") # Has trailing slash
|
|
@router.get("/endpoint") # No trailing slash
|
|
```
|
|
|
|
2. **Frontend API Call** - Does it match the backend?
|
|
```typescript
|
|
apiClient.get(`/endpoint/`) // Has trailing slash
|
|
apiClient.get(`/endpoint`) // No trailing slash
|
|
```
|
|
|
|
3. **Query Parameters** - Trailing slash goes BEFORE the `?`
|
|
```typescript
|
|
// ✅ CORRECT
|
|
apiClient.get(`/tasks/?shot_id=12`)
|
|
|
|
// ❌ WRONG
|
|
apiClient.get(`/tasks?shot_id=12/`)
|
|
```
|
|
|
|
4. **Path Parameters** - Usually no trailing slash
|
|
```typescript
|
|
// ✅ CORRECT
|
|
apiClient.get(`/tasks/${taskId}`)
|
|
|
|
// ❌ WRONG (usually)
|
|
apiClient.get(`/tasks/${taskId}/`)
|
|
```
|
|
|
|
## Historical Issues Fixed
|
|
|
|
### Issue 1: Shots Endpoint (Fixed)
|
|
- **Problem:** Frontend called `/shots/1/` but backend defined `/shots/{shot_id}`
|
|
- **Solution:** Changed frontend to call `/shots/1` (no trailing slash)
|
|
- **Files:** `frontend/src/services/shot.ts`
|
|
|
|
### Issue 2: Tasks Endpoint (Fixed)
|
|
- **Problem:** Frontend called `/tasks?shot_id=12` but backend defined `/tasks/`
|
|
- **Solution:** Changed frontend to call `/tasks/?shot_id=12` (with trailing slash)
|
|
- **Files:** `frontend/src/services/task.ts`
|
|
|
|
## Testing
|
|
|
|
To verify there's no redirect issue:
|
|
|
|
1. **Check backend logs** - Should see only ONE request, not two:
|
|
```
|
|
✅ GOOD:
|
|
INFO: "GET /tasks/?shot_id=12 HTTP/1.1" 200 OK
|
|
|
|
❌ BAD (redirect happening):
|
|
INFO: "GET /tasks?shot_id=12 HTTP/1.1" 307 Temporary Redirect
|
|
INFO: "GET /tasks/?shot_id=12 HTTP/1.1" 403 Forbidden
|
|
```
|
|
|
|
2. **Check frontend network tab** - Should see 200 OK, not 307 or 403
|
|
|
|
3. **Test with authentication** - Ensure authenticated endpoints work correctly
|
|
|
|
## Quick Reference
|
|
|
|
### Common Patterns
|
|
|
|
| Endpoint Type | Backend Route | Frontend Call |
|
|
|--------------|---------------|---------------|
|
|
| List with query params | `@router.get("/items/")` | `get("/items/?param=value")` |
|
|
| Get by ID | `@router.get("/items/{id}")` | `get("/items/123")` |
|
|
| Create | `@router.post("/items/")` | `post("/items/", data)` |
|
|
| Update by ID | `@router.put("/items/{id}")` | `put("/items/123", data)` |
|
|
| Delete by ID | `@router.delete("/items/{id}")` | `delete("/items/123")` |
|
|
|
|
## Related Files
|
|
|
|
- Backend routes: `backend/routers/*.py`
|
|
- Frontend services: `frontend/src/services/*.ts`
|
|
- API client: `frontend/src/services/api.ts`
|
|
|
|
## Additional Notes
|
|
|
|
- This issue only affects authenticated endpoints because the `Authorization` header is lost during redirect
|
|
- Public endpoints might not show this issue as clearly
|
|
- Always test with actual authentication tokens, not just in development mode
|
|
- Consider adding a linter rule or pre-commit hook to check for trailing slash consistency
|