From 89fa9b4b49630c61e25e051ab9ba3e71472a2496 Mon Sep 17 00:00:00 2001 From: Wirasm <152263317+Wirasm@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:18:53 +0300 Subject: [PATCH] Include description in tasks polling ETag (#698) * Include description in tasks polling ETag * Align tasks endpoint headers with HTTP cache expectations --- python/src/server/api_routes/projects_api.py | 66 ++++++++++++++------ 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/python/src/server/api_routes/projects_api.py b/python/src/server/api_routes/projects_api.py index c1c114e..98e7576 100644 --- a/python/src/server/api_routes/projects_api.py +++ b/python/src/server/api_routes/projects_api.py @@ -9,7 +9,8 @@ Handles: """ 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 @@ -578,20 +579,45 @@ 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) @@ -599,14 +625,18 @@ async def list_project_tasks( 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}" @@ -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)})