feat(errors): provide readable Redmine API error messages

- Introduce RedmineAPIError with human-friendly messages.
- Distinguish AuthError, ForbiddenError, HTTP status codes, timeouts
  and connection errors in client.py.
- Update CLI to print the readable message instead of generic
  "Redmine API error: ...".
- Log original exception with traceback when --debug is enabled.
- Add tests for all error paths and CLI output.

Closes #39
This commit is contained in:
Кокос Артем Николаевич
2026-06-29 14:58:21 +07:00
parent 222d31730e
commit f80f3a8b52
4 changed files with 195 additions and 10 deletions

View File

@@ -7,7 +7,7 @@ from datetime import datetime
from typing import List, Optional
from . import __version__
from .client import fetch_issues_with_spent_time
from .client import RedmineAPIError, fetch_issues_with_spent_time
from .config import Config
from .formatters.factory import get_console_formatter, get_formatter_by_extension
from .report_builder import build_grouped_report, calculate_summary
@@ -106,8 +106,13 @@ def main(argv: Optional[List[str]] = None) -> int:
try:
issue_hours = fetch_issues_with_spent_time(from_date, to_date)
except RedmineAPIError as e:
print(f"{e.message}", file=sys.stderr)
if args.debug and e.original is not None:
logging.exception("Original Redmine API error")
return 1
except Exception as e:
print(f"Redmine API error: {e}", file=sys.stderr)
print(f"Unexpected error: {e}", file=sys.stderr)
return 1
if issue_hours is None:

View File

@@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional, Tuple
import requests
from redminelib import Redmine
from redminelib.exceptions import AuthError, ForbiddenError
from redminelib.resources import Issue
from urllib3.util.retry import Retry
@@ -14,6 +15,15 @@ REQUEST_TIMEOUT = 30
ISSUE_ID_CHUNK_SIZE = 100
class RedmineAPIError(Exception):
"""Пользовательское исключение с понятным сообщением об ошибке Redmine API."""
def __init__(self, message: str, original: Optional[Exception] = None):
super().__init__(message)
self.message = message
self.original = original
def _get_redmine_auth_kwargs() -> Dict[str, Any]:
"""Return Redmine auth kwargs. API key has priority over legacy password auth."""
api_key = Config.get_redmine_api_key()
@@ -56,6 +66,49 @@ def _create_redmine() -> Redmine:
return redmine
def _format_redmine_error(exc: Exception) -> str:
"""Преобразует исключение Redmine/requests в понятное сообщение."""
if isinstance(exc, AuthError):
return (
"Authentication failed: invalid API key, login or password. "
"Check REDMINE_API_KEY / REDMINE_USER / REDMINE_PASSWORD."
)
if isinstance(exc, ForbiddenError):
return (
"Access denied: your Redmine account does not have permission "
"to read time entries or issues."
)
# requests HTTPError может быть обёрнуто в python-redmine
original = getattr(exc, "response", None)
if original is None:
original = exc
response = getattr(original, "response", None)
if response is not None and hasattr(response, "status_code"):
status = response.status_code
if status == 401:
return "Authentication failed (HTTP 401): check your API key or login/password."
if status == 403:
return "Access denied (HTTP 403): insufficient Redmine permissions."
if status == 404:
return "Redmine endpoint not found (HTTP 404): check REDMINE_URL."
if status == 429:
return "Too many requests (HTTP 429): Redmine rate limit exceeded."
if 500 <= status < 600:
return f"Redmine server error (HTTP {status}): try again later."
return f"Redmine API returned HTTP {status}."
if isinstance(exc, requests.exceptions.Timeout):
return f"Redmine request timed out after {REQUEST_TIMEOUT} seconds."
if isinstance(exc, requests.exceptions.ConnectionError):
return "Cannot connect to Redmine: check the URL and network."
if isinstance(exc, requests.exceptions.RequestException):
return f"Network error while calling Redmine: {exc}"
return str(exc)
def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = []
@@ -74,14 +127,17 @@ def fetch_issues_with_spent_time(
Fetch unique issues linked to time entries of the current user in given date range,
along with total spent hours per issue.
Returns list of (issue, total_hours) tuples.
Raises RedmineAPIError on API/auth/network failures.
"""
redmine = _create_redmine()
current_user = redmine.user.get("current")
time_entries = redmine.time_entry.filter(
user_id=current_user.id, from_date=from_date, to_date=to_date
)
try:
redmine = _create_redmine()
current_user = redmine.user.get("current")
time_entries = redmine.time_entry.filter(
user_id=current_user.id, from_date=from_date, to_date=to_date
)
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Агрегируем часы по issue.id
spent_time: Dict[int, float] = {}
@@ -96,8 +152,11 @@ def fetch_issues_with_spent_time(
return None
# Загружаем полные объекты задач чанками (#21)
sorted_ids = sorted(issue_ids)
issues = _fetch_issues_chunked(redmine, sorted_ids)
try:
sorted_ids = sorted(issue_ids)
issues = _fetch_issues_chunked(redmine, sorted_ids)
except Exception as exc:
raise RedmineAPIError(_format_redmine_error(exc), original=exc) from exc
# Сопоставляем задачи с суммарным временем.
# Сортировка выполняется в report_builder.build_grouped_report,