|
14 | 14 | RESTIC_CACHE_PATH = "/data/restic-cache" |
15 | 15 |
|
16 | 16 |
|
| 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 | + |
17 | 35 | def _normalize_mirror(value: str) -> str: |
18 | 36 | normalized = value.strip().rstrip("/") |
19 | 37 | if normalized.startswith("http://"): |
@@ -161,29 +179,39 @@ def _run_restic_container(self, command: List[str], extra_volumes: Dict[str, Dic |
161 | 179 | except docker.errors.ContainerError as e: |
162 | 180 | stderr_output = e.stderr.decode("utf-8") if e.stderr else str(e) |
163 | 181 | 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. |
167 | 185 | for line in stderr_output.strip().split("\n"): |
168 | 186 | if not line.strip(): |
169 | 187 | continue |
170 | 188 | try: |
171 | 189 | 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 |
179 | 190 | 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 |
181 | 203 | 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 |
187 | 215 | raise CustomException(500, msg, "Restic Container Error") |
188 | 216 | except Exception as e: |
189 | 217 | raise CustomException(500, f"Restic container failed: {e}", "Container Error") |
@@ -286,13 +314,8 @@ def create_backup(self, app_id: str) -> None: |
286 | 314 | msg_type = data.get("message_type") or "" |
287 | 315 | if msg_type == "summary" and "snapshot_id" in data: |
288 | 316 | 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) |
296 | 319 |
|
297 | 320 | if backup_error: |
298 | 321 | 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: |
374 | 397 | msg_type = data.get("message_type") or "" |
375 | 398 | if msg_type == "summary": |
376 | 399 | 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) |
384 | 402 |
|
385 | 403 | if restore_error: |
386 | 404 | raise CustomException(500, restore_error, f"Restic restore failed for snapshot: {snapshot_id}") |
|
0 commit comments