5 Commits

Author SHA1 Message Date
4171159264 Implement long operation timeouts for WebDAV client
Added a timeout for long WebDAV operations to improve reliability.
2026-01-14 10:44:49 +01:00
a67a631c99 Implement spooling of byte stream to temp file
Added functionality to spool an async byte stream to a temporary file for improved upload handling.
2026-01-14 10:44:24 +01:00
99c362b6d4 Add SPOOL_FLUSH_BYTES constant for uploads
Added constant for spooling to temporary file during uploads.
2026-01-14 10:44:00 +01:00
76715585ab Update manifest.json for version 0.1.1-alpha
Updated version number and links in manifest.
2026-01-14 10:43:18 +01:00
15e6ae9ab7 Update CHANGELOG for version 0.1.1-alpha
Improved upload reliability and adjusted client timeout for WebDAV.
2026-01-14 10:42:24 +01:00
5 changed files with 142 additions and 15 deletions

View File

@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
## [0.1.1-alpha] - 2026-01-14
### Fixed
- Improved upload reliability by spooling backup streams to a temporary file and uploading with Content-Length (avoids chunked WebDAV uploads that may cause reverse proxy 504 timeouts).
- Set a non-restrictive client timeout for WebDAV PUT requests to prevent client-side premature aborts on slow connections.
## [0.1.0-alpha] - 2026-01-14 ## [0.1.0-alpha] - 2026-01-14
### Added ### Added
- Initial alpha release - Initial alpha release

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import os
import tempfile
from collections.abc import AsyncIterator, Callable, Coroutine from collections.abc import AsyncIterator, Callable, Coroutine
from typing import Any from typing import Any
@@ -20,6 +22,7 @@ from .const import (
DATA_CLIENT, DATA_CLIENT,
DOMAIN, DOMAIN,
META_SUFFIX, META_SUFFIX,
SPOOL_FLUSH_BYTES,
TAR_PREFIX, TAR_PREFIX,
TAR_SUFFIX, TAR_SUFFIX,
) )
@@ -44,6 +47,50 @@ def _agentbackup_from_dict(d: dict[str, Any]) -> AgentBackup:
return AgentBackup(**d) # type: ignore[arg-type] return AgentBackup(**d) # type: ignore[arg-type]
async def _spool_stream_to_tempfile(stream: AsyncIterator[bytes]) -> tuple[str, int]:
"""Spool an async byte stream into a temporary file and return (path, size).
This avoids chunked WebDAV uploads and improves compatibility with reverse proxies.
"""
fd, path = tempfile.mkstemp(prefix="owncloud_backup_", suffix=".tar")
os.close(fd)
size = 0
buf = bytearray()
try:
async for chunk in stream:
if not chunk:
continue
buf.extend(chunk)
size += len(chunk)
if len(buf) >= SPOOL_FLUSH_BYTES:
data = bytes(buf)
buf.clear()
await asyncio.to_thread(_write_bytes_to_file, path, data, append=True)
if buf:
await asyncio.to_thread(_write_bytes_to_file, path, bytes(buf), append=True)
return path, size
except Exception:
# Ensure no leftovers on failure
try:
os.remove(path)
except OSError:
pass
raise
def _write_bytes_to_file(path: str, data: bytes, *, append: bool) -> None:
mode = "ab" if append else "wb"
with open(path, mode) as f:
f.write(data)
f.flush()
class OwnCloudBackupAgent(BackupAgent): class OwnCloudBackupAgent(BackupAgent):
"""Backup agent storing backups in ownCloud via WebDAV.""" """Backup agent storing backups in ownCloud via WebDAV."""
@@ -61,21 +108,35 @@ class OwnCloudBackupAgent(BackupAgent):
backup: AgentBackup, backup: AgentBackup,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
"""Upload a backup + metadata sidecar.""" """Upload a backup + metadata sidecar.
To avoid chunked uploads (which often break behind proxies), we spool
the stream to a temp file and upload with a Content-Length.
"""
temp_path: str | None = None
try: try:
tar_name = _make_tar_name(backup.backup_id) tar_name = _make_tar_name(backup.backup_id)
meta_name = _make_meta_name(backup.backup_id) meta_name = _make_meta_name(backup.backup_id)
# 1) Upload tar stream # 1) Spool tar stream to temp file
stream = await open_stream() stream = await open_stream()
await self._client.put_stream(tar_name, stream) temp_path, size = await _spool_stream_to_tempfile(stream)
# 2) Upload metadata JSON (small) # 2) Upload tar file with Content-Length
await self._client.put_file(tar_name, temp_path, size)
# 3) Upload metadata JSON (small)
meta_bytes = json.dumps(backup.to_dict(), ensure_ascii=False).encode("utf-8") meta_bytes = json.dumps(backup.to_dict(), ensure_ascii=False).encode("utf-8")
await self._client.put_bytes(meta_name, meta_bytes) await self._client.put_bytes(meta_name, meta_bytes)
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
raise BackupAgentError(f"Upload to ownCloud failed: {err}") from err raise BackupAgentError(f"Upload to ownCloud failed: {err}") from err
finally:
if temp_path:
try:
os.remove(temp_path)
except OSError:
pass
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups by reading metadata sidecars; fallback to tar stat if missing.""" """List backups by reading metadata sidecars; fallback to tar stat if missing."""
@@ -217,3 +278,4 @@ def async_register_backup_agents_listener(
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
return remove_listener return remove_listener

View File

@@ -14,3 +14,6 @@ DATA_BACKUP_AGENT_LISTENERS = "backup_agent_listeners"
TAR_PREFIX = "ha_backup_" TAR_PREFIX = "ha_backup_"
TAR_SUFFIX = ".tar" TAR_SUFFIX = ".tar"
META_SUFFIX = ".json" META_SUFFIX = ".json"
# Spooling to temp file to avoid chunked WebDAV uploads
SPOOL_FLUSH_BYTES = 1024 * 1024 # 1 MiB

View File

@@ -1,12 +1,14 @@
{ {
"domain": "owncloud_backup", "domain": "owncloud_backup",
"name": "ownCloud Backup (WebDAV)", "name": "ownCloud Backup (WebDAV)",
"version": "0.1.0-alpha", "version": "0.1.1-alpha",
"documentation": "https://github.com/your-org/owncloud-backup-ha", "documentation": "https://github.com/bahmcloud/owncloud-backup-ha/",
"issue_tracker": "https://github.com/your-org/owncloud-backup-ha/issues", "issue_tracker": "https://github.com/bahmcloud/owncloud-backup-ha/",
"codeowners": [], "codeowners": [BAHMCLOUD],
"config_flow": true, "config_flow": true,
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"requirements": [] "requirements": []
} }

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import base64 import base64
import logging import logging
import os
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -9,6 +10,7 @@ from email.utils import parsedate_to_datetime
from typing import Final from typing import Final
from urllib.parse import quote, urljoin from urllib.parse import quote, urljoin
import aiohttp
from aiohttp import ClientResponseError, ClientSession from aiohttp import ClientResponseError, ClientSession
from yarl import URL from yarl import URL
@@ -46,6 +48,11 @@ class WebDavClient:
] ]
self._cached_root: str | None = None self._cached_root: str | None = None
# Non-restrictive client timeouts for potentially long WebDAV operations
self._timeout_long = aiohttp.ClientTimeout(
total=None, connect=60, sock_connect=60, sock_read=None
)
def _auth_header(self) -> str: def _auth_header(self) -> str:
token = base64.b64encode(f"{self._username}:{self._password}".encode("utf-8")).decode("ascii") token = base64.b64encode(f"{self._username}:{self._password}".encode("utf-8")).decode("ascii")
return f"Basic {token}" return f"Basic {token}"
@@ -82,6 +89,7 @@ class WebDavClient:
b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>' b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
), ),
raise_for_status=True, raise_for_status=True,
timeout=self._timeout_long,
): ):
self._cached_root = root self._cached_root = root
return root return root
@@ -106,6 +114,7 @@ class WebDavClient:
base_folder, base_folder,
headers=self._headers({"Depth": "0"}), headers=self._headers({"Depth": "0"}),
raise_for_status=True, raise_for_status=True,
timeout=self._timeout_long,
): ):
return return
except ClientResponseError as err: except ClientResponseError as err:
@@ -127,7 +136,11 @@ class WebDavClient:
# exists? # exists?
try: try:
async with self._session.request( async with self._session.request(
"PROPFIND", url, headers=self._headers({"Depth": "0"}), raise_for_status=True "PROPFIND",
url,
headers=self._headers({"Depth": "0"}),
raise_for_status=True,
timeout=self._timeout_long,
): ):
return return
except ClientResponseError as err: except ClientResponseError as err:
@@ -135,7 +148,9 @@ class WebDavClient:
raise raise
# create # create
async with self._session.request("MKCOL", url, headers=self._headers()) as resp: async with self._session.request(
"MKCOL", url, headers=self._headers(), timeout=self._timeout_long
) as resp:
if resp.status in (201, 405): if resp.status in (201, 405):
return return
text = await resp.text() text = await resp.text()
@@ -154,6 +169,7 @@ class WebDavClient:
b'<d:propfind xmlns:d="DAV:"><d:prop><d:displayname/></d:prop></d:propfind>' b'<d:propfind xmlns:d="DAV:"><d:prop><d:displayname/></d:prop></d:propfind>'
), ),
raise_for_status=True, raise_for_status=True,
timeout=self._timeout_long,
) as resp: ) as resp:
body = await resp.text() body = await resp.text()
@@ -195,10 +211,42 @@ class WebDavClient:
async def put_bytes(self, name: str, data: bytes) -> None: async def put_bytes(self, name: str, data: bytes) -> None:
folder = await self._base_folder_url() folder = await self._base_folder_url()
url = self._file_url(folder, name) url = self._file_url(folder, name)
async with self._session.put(url, data=data, headers=self._headers(), raise_for_status=True): async with self._session.put(
url,
data=data,
headers=self._headers({"Content-Length": str(len(data))}),
raise_for_status=True,
timeout=self._timeout_long,
):
return return
async def put_file(self, name: str, path: str, size: int) -> None:
"""Upload a local file with an explicit Content-Length (non-chunked)."""
folder = await self._base_folder_url()
url = self._file_url(folder, name)
# Ensure correct size if caller passes 0/unknown
if size <= 0:
try:
size = os.path.getsize(path)
except OSError:
size = 0
headers = {"Content-Length": str(size)} if size > 0 else {}
# aiohttp will stream file content; with Content-Length set, proxies are usually happier.
with open(path, "rb") as f:
async with self._session.put(
url,
data=f,
headers=self._headers(headers),
raise_for_status=True,
timeout=self._timeout_long,
):
return
async def put_stream(self, name: str, stream: AsyncIterator[bytes]) -> None: async def put_stream(self, name: str, stream: AsyncIterator[bytes]) -> None:
"""Legacy method: chunked upload. Prefer put_file for better proxy compatibility."""
folder = await self._base_folder_url() folder = await self._base_folder_url()
url = self._file_url(folder, name) url = self._file_url(folder, name)
@@ -206,13 +254,19 @@ class WebDavClient:
async for chunk in stream: async for chunk in stream:
yield chunk yield chunk
async with self._session.put(url, data=gen(), headers=self._headers(), raise_for_status=True): async with self._session.put(
url,
data=gen(),
headers=self._headers(),
raise_for_status=True,
timeout=self._timeout_long,
):
return return
async def get_bytes(self, name: str) -> bytes: async def get_bytes(self, name: str) -> bytes:
folder = await self._base_folder_url() folder = await self._base_folder_url()
url = self._file_url(folder, name) url = self._file_url(folder, name)
async with self._session.get(url, headers=self._headers()) as resp: async with self._session.get(url, headers=self._headers(), timeout=self._timeout_long) as resp:
if resp.status == 404: if resp.status == 404:
raise FileNotFoundError(name) raise FileNotFoundError(name)
resp.raise_for_status() resp.raise_for_status()
@@ -221,7 +275,7 @@ class WebDavClient:
async def get_stream(self, name: str) -> AsyncIterator[bytes]: async def get_stream(self, name: str) -> AsyncIterator[bytes]:
folder = await self._base_folder_url() folder = await self._base_folder_url()
url = self._file_url(folder, name) url = self._file_url(folder, name)
resp = await self._session.get(url, headers=self._headers()) resp = await self._session.get(url, headers=self._headers(), timeout=self._timeout_long)
if resp.status == 404: if resp.status == 404:
await resp.release() await resp.release()
raise FileNotFoundError(name) raise FileNotFoundError(name)
@@ -239,7 +293,7 @@ class WebDavClient:
async def delete(self, name: str) -> None: async def delete(self, name: str) -> None:
folder = await self._base_folder_url() folder = await self._base_folder_url()
url = self._file_url(folder, name) url = self._file_url(folder, name)
async with self._session.delete(url, headers=self._headers()) as resp: async with self._session.delete(url, headers=self._headers(), timeout=self._timeout_long) as resp:
if resp.status == 404: if resp.status == 404:
raise FileNotFoundError(name) raise FileNotFoundError(name)
if resp.status in (200, 202, 204): if resp.status in (200, 202, 204):
@@ -267,6 +321,7 @@ class WebDavClient:
url, url,
headers=self._headers({"Depth": "0", "Content-Type": "application/xml; charset=utf-8"}), headers=self._headers({"Depth": "0", "Content-Type": "application/xml; charset=utf-8"}),
data=body, data=body,
timeout=self._timeout_long,
) as resp: ) as resp:
if resp.status == 404: if resp.status == 404:
raise FileNotFoundError(name) raise FileNotFoundError(name)