LinkDesk/backend/docs/fastapi-trailing-slash-issu...

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