Skip to content

Commit 359081f

Browse files
authored
fix: Handle partial sync failures gracefully (issue #19) (#20)
* fix: Handle partial sync failures gracefully (issue #19) Previously, if one Polar API endpoint returned an error (e.g., 403 on sleep due to missing consents), the entire sync would fail and no data would be synced. Now: - Each endpoint is wrapped in try/except - failures don't stop other syncs - SyncResult dataclass tracks both successful records and errors - 403 errors include actionable guidance about Polar consent settings - Admin UI shows partial success with both synced data and error details - SyncOrchestrator properly logs partial success status Also bumps version to 1.3.3 with updated CHANGELOG. Closes #19 * feat: Manual sync now creates SyncLog entries for audit trail - Admin "Sync Now" button now uses SyncOrchestrator instead of SyncService directly - This ensures all manual syncs are properly logged in sync_logs table - SyncOrchestrator now stores error_details for partial success (not just total failure) - Updated CHANGELOG with manual sync audit logging fix * feat: Add expandable sync log details in admin dashboard - Click any sync log row to expand/collapse detailed view - Shows breakdown of records synced per endpoint - Shows error type and message for failed/partial syncs - Shows analytics status (baselines, patterns, insights) - Shows metadata (job ID, API calls, priority) - Updated Records column to show total count instead of "X types" * style: Fix ruff formatting
1 parent de770ef commit 359081f

File tree

14 files changed

+696
-92
lines changed

14 files changed

+696
-92
lines changed

.github/workflows/claude-code-review.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Claude Code Review
22

33
on:
44
pull_request:
5-
types: [opened, synchronize, ready_for_review, reopened]
5+
types: [opened, ready_for_review, reopened]
66

77
jobs:
88
claude-review:

CHANGELOG.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
---
1111

12+
## [1.3.3] - 2026-01-21
13+
14+
### Fixed
15+
16+
**Partial Sync Failure Handling (Issue #19)**
17+
- Sync now continues when individual Polar API endpoints fail (e.g., 403 on sleep)
18+
- Previously, a single endpoint failure would stop the entire sync with no data saved
19+
- Now syncs each endpoint independently - failures are captured but don't block other data
20+
21+
**Manual Sync Audit Logging**
22+
- Admin "Sync Now" button now creates `SyncLog` entries for proper audit trail
23+
- Previously, manual syncs bypassed `SyncOrchestrator` and weren't logged
24+
25+
### Added
26+
27+
- `SyncResult` dataclass to track both successful records and per-endpoint errors
28+
- Actionable error messages for common HTTP errors:
29+
- 403: Guidance about Polar data sharing consent settings
30+
- 401: Token expiration/re-authentication needed
31+
- 429: Rate limit information
32+
- Partial success UI template showing both synced data and errors
33+
- Dynamic endpoint display in templates (all 13 data types now shown)
34+
- API sync endpoint now returns `status: "partial"` with error details when some endpoints fail
35+
- Expandable sync log rows in admin dashboard showing full details (records, errors, analytics, metadata)
36+
37+
### Changed
38+
39+
- `SyncService.sync_user()` now returns `SyncResult` instead of `dict[str, int]`
40+
- `SyncOrchestrator` properly tracks partial success status in `SyncLog`
41+
- Admin sync templates dynamically iterate over all endpoints instead of hardcoding
42+
43+
---
44+
45+
## [1.3.2] - 2026-01-20
46+
47+
### Fixed
48+
49+
- CSRF cookie configuration for proxy deployments
50+
- Admin route CSRF exclusions
51+
52+
---
53+
54+
## [1.3.1] - 2026-01-20
55+
56+
### Fixed
57+
58+
- Version mismatch between pyproject.toml and __init__.py
59+
60+
---
61+
62+
## [1.3.0] - 2026-01-20
63+
64+
### Added
65+
66+
- MCP server integration for Claude Desktop
67+
- Additional Polar SDK endpoints (cardio load, sleepwise, biosensing)
68+
69+
---
70+
1271
## [1.2.0] - 2026-01-13
1372

1473
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "polar-flow-server"
3-
version = "1.3.2"
3+
version = "1.3.3"
44
description = "Self-hosted health analytics server for Polar devices"
55
readme = "README.md"
66
authors = [

src/polar_flow_server/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""polar-flow-server - Self-hosted health analytics server for Polar devices."""
22

3-
__version__ = "1.3.2"
3+
__version__ = "1.3.3"
44

55
__all__ = ["__version__"]

src/polar_flow_server/admin/routes.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,11 @@
4949
from polar_flow_server.models.sleepwise_alertness import SleepWiseAlertness
5050
from polar_flow_server.models.sleepwise_bedtime import SleepWiseBedtime
5151
from polar_flow_server.models.spo2 import SpO2
52-
from polar_flow_server.models.sync_log import SyncLog
52+
from polar_flow_server.models.sync_log import SyncLog, SyncTrigger
5353
from polar_flow_server.models.temperature import BodyTemperature, SkinTemperature
5454
from polar_flow_server.models.user import User
5555
from polar_flow_server.services.scheduler import get_scheduler
56-
from polar_flow_server.services.sync import SyncService
56+
from polar_flow_server.services.sync_orchestrator import SyncOrchestrator
5757

5858
logger = logging.getLogger(__name__)
5959

@@ -918,24 +918,55 @@ async def trigger_manual_sync(request: Request[Any, Any, Any], session: AsyncSes
918918
polar_token = env_token
919919
user_id = "self"
920920

921-
# Run sync
922-
sync_service = SyncService(session)
921+
# Run sync via orchestrator (creates SyncLog entry for audit trail)
922+
orchestrator = SyncOrchestrator(session)
923923
try:
924-
results = await sync_service.sync_user(
924+
sync_log = await orchestrator.sync_user(
925925
user_id=user_id,
926926
polar_token=polar_token,
927-
days=settings.sync_days_lookback,
927+
trigger=SyncTrigger.MANUAL,
928928
)
929929

930930
# Get updated counts
931931
sleep_count = (await session.execute(select(func.count(Sleep.id)))).scalar() or 0
932932
exercise_count = (await session.execute(select(func.count(Exercise.id)))).scalar() or 0
933933
activity_count = (await session.execute(select(func.count(Activity.id)))).scalar() or 0
934934

935+
# Check sync status
936+
if sync_log.status == "partial":
937+
# Partial success - some endpoints worked, some failed
938+
errors = sync_log.error_details.get("errors", {}) if sync_log.error_details else {}
939+
return Template(
940+
template_name="admin/partials/sync_partial.html",
941+
context={
942+
"results": sync_log.records_synced or {},
943+
"errors": errors,
944+
"sleep_count": sleep_count,
945+
"exercise_count": exercise_count,
946+
"activity_count": activity_count,
947+
},
948+
)
949+
elif sync_log.status == "failed":
950+
# Total failure - all endpoints failed
951+
errors_raw = sync_log.error_details.get("errors", {}) if sync_log.error_details else {}
952+
# errors_raw is dict[str, str] but typed as object, cast for iteration
953+
errors = errors_raw if isinstance(errors_raw, dict) else {}
954+
if errors:
955+
error_messages = "\n".join(
956+
f"• {endpoint}: {msg}" for endpoint, msg in errors.items()
957+
)
958+
else:
959+
error_messages = sync_log.error_message or "Sync failed"
960+
return Template(
961+
template_name="admin/partials/sync_error.html",
962+
context={"error": error_messages},
963+
)
964+
965+
# Full success - no errors
935966
return Template(
936967
template_name="admin/partials/sync_success.html",
937968
context={
938-
"results": results,
969+
"results": sync_log.records_synced or {},
939970
"sleep_count": sleep_count,
940971
"exercise_count": exercise_count,
941972
"activity_count": activity_count,

src/polar_flow_server/api/sync.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Sync API endpoints."""
22

3-
from typing import Annotated
3+
from typing import Annotated, Any
44

55
from litestar import Router, post
66
from litestar.params import Parameter
@@ -20,7 +20,7 @@ async def trigger_sync(
2020
session: AsyncSession,
2121
polar_token: Annotated[str, Parameter(header="X-Polar-Token")],
2222
days: Annotated[int | None, Parameter(query="days", ge=1, le=365)] = None,
23-
) -> dict[str, str | dict[str, int]]:
23+
) -> dict[str, Any]:
2424
"""Trigger data sync for a user.
2525
2626
Args:
@@ -30,24 +30,26 @@ async def trigger_sync(
3030
days: Number of days to sync (optional, uses config default)
3131
3232
Returns:
33-
Sync results with counts per data type
33+
Sync results with counts per data type and any errors
3434
3535
Example:
3636
POST /api/v1/users/12345/sync/trigger?days=30
3737
Headers: X-Polar-Token: <access_token>
3838
"""
3939
sync_service = SyncService(session)
4040

41-
results = await sync_service.sync_user(
41+
sync_result = await sync_service.sync_user(
4242
user_id=user_id,
4343
polar_token=polar_token,
4444
days=days,
4545
)
4646

4747
return {
48-
"status": "success",
48+
"status": "partial" if sync_result.has_errors else "success",
4949
"user_id": user_id,
50-
"results": results,
50+
"records": sync_result.records,
51+
"errors": sync_result.errors if sync_result.has_errors else None,
52+
"total_records": sync_result.total_records,
5153
}
5254

5355

src/polar_flow_server/services/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
from polar_flow_server.services.insights import InsightsService
55
from polar_flow_server.services.observations import ObservationGenerator
66
from polar_flow_server.services.pattern import AnomalyService, PatternService
7-
from polar_flow_server.services.sync import SyncService
7+
from polar_flow_server.services.sync import SyncResult, SyncService
88

99
__all__ = [
1010
"AnomalyService",
1111
"BaselineService",
1212
"InsightsService",
1313
"ObservationGenerator",
1414
"PatternService",
15+
"SyncResult",
1516
"SyncService",
1617
]

0 commit comments

Comments
 (0)