Skip to content

Commit 479e10f

Browse files
committed
fix: handle Restic v0.19+ exit_error message format in error extraction
Restic v0.19+ outputs fatal errors with message_type 'exit_error' (not 'error') and the message is at the top level (not nested in error.message). Added _extract_restic_error() helper that handles both formats plus future variants. Also fixes the output parsing in both create_backup and restore_backup to recognize exit_error/fatal_error message types in addition to the legacy error format.
1 parent 9da8840 commit 479e10f

1 file changed

Lines changed: 48 additions & 30 deletions

File tree

apphub/src/services/back_manager.py

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@
1414
RESTIC_CACHE_PATH = "/data/restic-cache"
1515

1616

17+
def _extract_restic_error(data: Dict[str, Any]) -> str:
18+
"""Extract a human-readable error message from a Restic JSON error line.
19+
20+
Restic v0.17+ uses ``message_type: "error"`` with a nested ``error.message``.
21+
Restic v0.19+ uses ``message_type: "exit_error"`` with a top-level ``message``.
22+
"""
23+
msg_type = data.get("message_type") or ""
24+
if msg_type in ("exit_error", "fatal_error"):
25+
return data.get("message") or "Fatal Restic error"
26+
if msg_type == "error":
27+
err_info = data.get("error", {})
28+
if isinstance(err_info, dict):
29+
return err_info.get("message") or str(err_info)
30+
return str(err_info) if err_info else "Unknown Restic error"
31+
# Fallback: any top-level message
32+
return data.get("message") or "Unknown Restic error"
33+
34+
1735
def _normalize_mirror(value: str) -> str:
1836
normalized = value.strip().rstrip("/")
1937
if normalized.startswith("http://"):
@@ -161,29 +179,39 @@ def _run_restic_container(self, command: List[str], extra_volumes: Dict[str, Dic
161179
except docker.errors.ContainerError as e:
162180
stderr_output = e.stderr.decode("utf-8") if e.stderr else str(e)
163181
msg = "Unknown error"
164-
# Try to extract a meaningful message from the Restic JSON error.
165-
# Restic may output JSON errors to stderr; parse line by line in
166-
# case stdout and stderr are interleaved.
182+
# Restic v0.19+ outputs fatal errors to stderr as JSON with
183+
# message_type "exit_error" (top-level "message" key). Older
184+
# versions may use "error" with a nested error.message.
167185
for line in stderr_output.strip().split("\n"):
168186
if not line.strip():
169187
continue
170188
try:
171189
data = json.loads(line)
172-
if data.get("message_type") == "error":
173-
err_info = data.get("error", {})
174-
if isinstance(err_info, dict):
175-
msg = err_info.get("message") or str(err_info)
176-
else:
177-
msg = str(err_info) if err_info else msg
178-
break
179190
except json.JSONDecodeError:
180-
pass
191+
continue
192+
msg_type = data.get("message_type") or ""
193+
if msg_type in ("exit_error", "fatal_error"):
194+
msg = data.get("message") or "Fatal Restic error"
195+
break
196+
if msg_type == "error":
197+
err_info = data.get("error", {})
198+
if isinstance(err_info, dict):
199+
msg = err_info.get("message") or str(err_info)
200+
else:
201+
msg = str(err_info) if err_info else msg
202+
break
181203
if msg == "Unknown error":
182-
# Fallback: try top-level message
183-
try:
184-
msg = json.loads(stderr_output.split("\n")[-1]).get("message", msg)
185-
except (json.JSONDecodeError, KeyError, IndexError):
186-
pass
204+
# Fallback: try top-level message from the last JSON line
205+
for line in reversed(stderr_output.strip().split("\n")):
206+
if not line.strip():
207+
continue
208+
try:
209+
fallback = json.loads(line).get("message")
210+
if fallback:
211+
msg = fallback
212+
break
213+
except json.JSONDecodeError:
214+
continue
187215
raise CustomException(500, msg, "Restic Container Error")
188216
except Exception as e:
189217
raise CustomException(500, f"Restic container failed: {e}", "Container Error")
@@ -286,13 +314,8 @@ def create_backup(self, app_id: str) -> None:
286314
msg_type = data.get("message_type") or ""
287315
if msg_type == "summary" and "snapshot_id" in data:
288316
summary_found = True
289-
elif msg_type == "error":
290-
err_info = data.get("error", {})
291-
if isinstance(err_info, dict):
292-
backup_error = err_info.get("message") or str(err_info)
293-
else:
294-
backup_error = str(err_info) if err_info else "Unknown Restic error"
295-
logger.error(f"Restic backup error: {backup_error}")
317+
elif msg_type in ("exit_error", "fatal_error", "error"):
318+
backup_error = _extract_restic_error(data)
296319

297320
if backup_error:
298321
raise CustomException(500, backup_error, f"Restic backup failed for app: {app_id}")
@@ -374,13 +397,8 @@ def restore_backup(self, app_id: str, snapshot_id: str) -> None:
374397
msg_type = data.get("message_type") or ""
375398
if msg_type == "summary":
376399
summary_found = True
377-
elif msg_type == "error":
378-
err_info = data.get("error", {})
379-
if isinstance(err_info, dict):
380-
restore_error = err_info.get("message") or str(err_info)
381-
else:
382-
restore_error = str(err_info) if err_info else "Unknown Restic error"
383-
logger.error(f"Restic restore error: {restore_error}")
400+
elif msg_type in ("exit_error", "fatal_error", "error"):
401+
restore_error = _extract_restic_error(data)
384402

385403
if restore_error:
386404
raise CustomException(500, restore_error, f"Restic restore failed for snapshot: {snapshot_id}")

0 commit comments

Comments
 (0)