Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 48 additions & 18 deletions python/src/server/api_routes/projects_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"""

import json
from datetime import datetime
from datetime import datetime, timezone
from email.utils import format_datetime
from typing import Any

from fastapi import APIRouter, Header, HTTPException, Request, Response
Expand Down Expand Up @@ -578,35 +579,64 @@ async def list_project_tasks(

tasks = result.get("tasks", [])

# Generate ETag from task data (excluding timestamps for consistency)
etag_data = {
"tasks": [{
"id": task.get("id"),
"title": task.get("title"),
"status": task.get("status"),
"task_order": task.get("task_order"),
"assignee": task.get("assignee"),
"priority": task.get("priority"),
"feature": task.get("feature")
} for task in tasks],
"project_id": project_id,
"count": len(tasks)
}
# Generate ETag from task data (includes description and updated_at to drive polling invalidation)
etag_tasks: list[dict[str, object]] = []
last_modified_dt: datetime | None = None

for task in tasks:
raw_updated = task.get("updated_at")
parsed_updated: datetime | None = None
if isinstance(raw_updated, datetime):
parsed_updated = raw_updated
elif isinstance(raw_updated, str):
try:
parsed_updated = datetime.fromisoformat(raw_updated.replace("Z", "+00:00"))
except ValueError:
parsed_updated = None

if parsed_updated is not None:
parsed_updated = parsed_updated.astimezone(timezone.utc)
if last_modified_dt is None or parsed_updated > last_modified_dt:
last_modified_dt = parsed_updated

etag_tasks.append(
{
"id": task.get("id") or "",
"title": task.get("title") or "",
"status": task.get("status") or "",
"task_order": task.get("task_order") or 0,
"assignee": task.get("assignee") or "",
"priority": task.get("priority") or "",
"feature": task.get("feature") or "",
"description": task.get("description") or "",
"updated_at": (
parsed_updated.isoformat()
if parsed_updated is not None
else (str(raw_updated) if raw_updated else "")
),
}
)

etag_data = {"tasks": etag_tasks, "project_id": project_id, "count": len(tasks)}
current_etag = generate_etag(etag_data)

# Check if client's ETag matches (304 Not Modified)
if check_etag(if_none_match, current_etag):
response.status_code = 304
response.headers["ETag"] = current_etag
response.headers["Cache-Control"] = "no-cache, must-revalidate"
response.headers["Last-Modified"] = datetime.utcnow().isoformat()
response.headers["Last-Modified"] = format_datetime(
last_modified_dt or datetime.now(timezone.utc)
)
logfire.debug(f"Tasks unchanged, returning 304 | project_id={project_id} | etag={current_etag}")
return None

# Set ETag headers for successful response
response.headers["ETag"] = current_etag
response.headers["Cache-Control"] = "no-cache, must-revalidate"
response.headers["Last-Modified"] = datetime.utcnow().isoformat()
response.headers["Last-Modified"] = format_datetime(
last_modified_dt or datetime.now(timezone.utc)
)

logfire.debug(
f"Project tasks retrieved | project_id={project_id} | task_count={len(tasks)} | etag={current_etag}"
Expand All @@ -617,7 +647,7 @@ async def list_project_tasks(
except HTTPException:
raise
except Exception as e:
logfire.error(f"Failed to list project tasks | error={str(e)} | project_id={project_id}")
logfire.error(f"Failed to list project tasks | project_id={project_id}", exc_info=True)
raise HTTPException(status_code=500, detail={"error": str(e)})


Expand Down