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

4.9 KiB

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.

Frontend Service:

// ❌ WRONG - No trailing slash
const response = await apiClient.get(`/tasks?${params}`)

// ✅ CORRECT - With trailing slash
const response = await apiClient.get(`/tasks/?${params}`)

Backend Route:

# Route defined WITH trailing slash
@router.get("/tasks/")
async def get_tasks(...):
    ...

Option 2: Remove Trailing Slash from Backend

Backend Route:

# Route defined WITHOUT trailing slash
@router.get("/tasks")
async def get_tasks(...):
    ...

Frontend Service:

// 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?

    @router.get("/endpoint/")  # Has trailing slash
    @router.get("/endpoint")   # No trailing slash
    
  2. Frontend API Call - Does it match the backend?

    apiClient.get(`/endpoint/`)  // Has trailing slash
    apiClient.get(`/endpoint`)   // No trailing slash
    
  3. Query Parameters - Trailing slash goes BEFORE the ?

    // ✅ CORRECT
    apiClient.get(`/tasks/?shot_id=12`)
    
    // ❌ WRONG
    apiClient.get(`/tasks?shot_id=12/`)
    
  4. Path Parameters - Usually no trailing slash

    // ✅ 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")
  • 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