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 typing import List, Optional
from . import __version__ 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 .config import Config
from .formatters.factory import get_console_formatter, get_formatter_by_extension from .formatters.factory import get_console_formatter, get_formatter_by_extension
from .report_builder import build_grouped_report, calculate_summary from .report_builder import build_grouped_report, calculate_summary
@@ -106,8 +106,13 @@ def main(argv: Optional[List[str]] = None) -> int:
try: try:
issue_hours = fetch_issues_with_spent_time(from_date, to_date) 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: except Exception as e:
print(f"Redmine API error: {e}", file=sys.stderr) print(f"Unexpected error: {e}", file=sys.stderr)
return 1 return 1
if issue_hours is None: if issue_hours is None:

View File

@@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional, Tuple
import requests import requests
from redminelib import Redmine from redminelib import Redmine
from redminelib.exceptions import AuthError, ForbiddenError
from redminelib.resources import Issue from redminelib.resources import Issue
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
@@ -14,6 +15,15 @@ REQUEST_TIMEOUT = 30
ISSUE_ID_CHUNK_SIZE = 100 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]: def _get_redmine_auth_kwargs() -> Dict[str, Any]:
"""Return Redmine auth kwargs. API key has priority over legacy password auth.""" """Return Redmine auth kwargs. API key has priority over legacy password auth."""
api_key = Config.get_redmine_api_key() api_key = Config.get_redmine_api_key()
@@ -56,6 +66,49 @@ def _create_redmine() -> Redmine:
return 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]: def _fetch_issues_chunked(redmine: Redmine, issue_ids: List[int]) -> List[Issue]:
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21).""" """Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
all_issues: List[Issue] = [] 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, Fetch unique issues linked to time entries of the current user in given date range,
along with total spent hours per issue. along with total spent hours per issue.
Returns list of (issue, total_hours) tuples. Returns list of (issue, total_hours) tuples.
Raises RedmineAPIError on API/auth/network failures.
""" """
redmine = _create_redmine() try:
redmine = _create_redmine()
current_user = redmine.user.get("current") current_user = redmine.user.get("current")
time_entries = redmine.time_entry.filter( time_entries = redmine.time_entry.filter(
user_id=current_user.id, from_date=from_date, to_date=to_date 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 # Агрегируем часы по issue.id
spent_time: Dict[int, float] = {} spent_time: Dict[int, float] = {}
@@ -96,8 +152,11 @@ def fetch_issues_with_spent_time(
return None return None
# Загружаем полные объекты задач чанками (#21) # Загружаем полные объекты задач чанками (#21)
sorted_ids = sorted(issue_ids) try:
issues = _fetch_issues_chunked(redmine, sorted_ids) 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, # Сортировка выполняется в report_builder.build_grouped_report,

View File

@@ -210,6 +210,46 @@ def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
assert "Total issues" in captured.err assert "Total issues" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_auth_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при ошибке аутентификации."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "Authentication failed" in captured.err
assert "Redmine API error" not in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_forbidden_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при недостаточных правах."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Access denied: no permission")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "Access denied" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при таймауте."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Redmine request timed out after 30 seconds")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "timed out" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path): def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):

View File

@@ -1,6 +1,8 @@
import os import os
from unittest import mock from unittest import mock
import pytest
from redmine_reporter.client import fetch_issues_with_spent_time from redmine_reporter.client import fetch_issues_with_spent_time
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
@@ -168,6 +170,85 @@ def test_fetch_uses_custom_verify_path(mock_redmine_class):
assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem" assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem"
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
"""AuthError превращается в RedmineAPIError с понятным сообщением."""
from redminelib.exceptions import AuthError
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = AuthError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Authentication failed"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_forbidden(mock_redmine_class):
"""ForbiddenError превращается в RedmineAPIError с понятным сообщением."""
from redminelib.exceptions import ForbiddenError
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = ForbiddenError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Access denied"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_timeout(mock_redmine_class):
"""requests Timeout превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = requests.exceptions.Timeout("timeout")
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="timed out"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_connection_error(mock_redmine_class):
"""ConnectionError превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
mock_redmine.user.get.side_effect = requests.exceptions.ConnectionError("no route")
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Cannot connect"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_raises_redmine_api_error_on_http_500(mock_redmine_class):
"""HTTP 500 превращается в RedmineAPIError с понятным сообщением."""
import requests
mock_redmine = mock_redmine_class.return_value
response = requests.Response()
response.status_code = 500
mock_redmine.user.get.side_effect = requests.exceptions.HTTPError(
"server error", response=response
)
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="server error"):
fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
# -- #24: Таймаут и retry -- # -- #24: Таймаут и retry --