Skip to content
Merged
Show file tree
Hide file tree
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
note body content, passwords, tokens, or server URLs — error strings are
scrubbed and the plan/report only ever hold metadata.

- **Retry & backoff for transient failures.** Every WebDAV network
operation (GET, PROPFIND, PUT, MKCOL) is now retried on transient
conditions — timeouts, connection drops, and HTTP 429/502/503/504 —
with small conservative defaults (3 attempts, 0.5s/1s/2s backoff).
Auth failures (401/403) and 404 are never retried. Both the attempt
budget and per-call timeout are configurable on `SyncConfig`
(`max_attempts`, `backoff_seconds`, `timeout_seconds`).

- **Retryable sync reports.** When a sync fails for a transient reason the
report (and `POST /sync/trigger`) carries `retryable: true`, a short
`transient_reason`, and a suggested `next_retry_after_seconds`. The
diagnostic log additionally records per-operation attempt counts so a
flaky NAS/Cloudflare/mobile link is easy to spot — still with no body
content, credentials, or server URLs.

### Improved

- **Conflict safety.** When a note changed both locally and remotely, the
Expand Down
9 changes: 9 additions & 0 deletions nexanote/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,12 @@ class SyncReportSchema(BaseModel):
conflicts: list[dict] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
plan: dict = Field(default_factory=dict)
# Network reliability — additive too. ``retryable`` flags a transient
# failure (timeout/connection/429/502/503/504) so clients can back off
# and retry instead of surfacing it as a hard error.
retryable: bool = False
next_retry_after_seconds: Optional[float] = None
transient_reason: Optional[str] = None


class ExportRequestSchema(BaseModel):
Expand Down Expand Up @@ -603,6 +609,9 @@ def trigger_sync(dry_run: bool = Query(False)):
conflicts=[c.to_dict() for c in plan.conflicts] if plan else [],
warnings=list(plan.warnings) if plan else [],
plan=plan.to_dict() if plan else {},
retryable=report.retryable,
next_retry_after_seconds=report.next_retry_after_seconds,
transient_reason=report.transient_reason,
)
# A dry-run is a preview — it must not clobber the last *real* status.
if not dry_run:
Expand Down
Loading
Loading