Add retry/backoff, timeout, and issue_id chunking to Redmine client
- #24: Mount HTTPAdapter with Retry(total=3, backoff_factor=0.5) for HTTP 429/500/502/503/504 on the Redmine session, set 30s timeout - #21: Split large issue_id lists into chunks of 100 to avoid exceeding URL length limits on reverse proxies Closes #24, #21
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
from redminelib import Redmine
|
||||
from redminelib.resources import Issue
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from .config import Config
|
||||
|
||||
# Таймаут на один HTTP-запрос к Redmine (секунды).
|
||||
REQUEST_TIMEOUT = 30
|
||||
|
||||
# Размер чанка для запроса задач по issue_id, чтобы не превышать лимит длины URL (#21).
|
||||
ISSUE_ID_CHUNK_SIZE = 100
|
||||
|
||||
|
||||
def _get_redmine_auth_kwargs() -> Dict[str, Any]:
|
||||
"""Return Redmine auth kwargs. API key has priority over legacy password auth."""
|
||||
@@ -17,6 +25,51 @@ def _get_redmine_auth_kwargs() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
|
||||
"""Создаёт HTTPAdapter с retry для временных ошибок (#24)."""
|
||||
retry = Retry(
|
||||
total=3,
|
||||
backoff_factor=0.5,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["GET", "HEAD", "OPTIONS"],
|
||||
)
|
||||
return requests.adapters.HTTPAdapter(max_retries=retry)
|
||||
|
||||
|
||||
def _create_redmine() -> Redmine:
|
||||
"""Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24)."""
|
||||
redmine = Redmine(
|
||||
Config.get_redmine_url(),
|
||||
**_get_redmine_auth_kwargs(),
|
||||
requests={
|
||||
"verify": Config.get_redmine_verify(),
|
||||
"timeout": REQUEST_TIMEOUT,
|
||||
},
|
||||
)
|
||||
|
||||
# Монтируем retry-адаптер на сессию для автоматических повторов
|
||||
retry_adapter = _make_retry_adapter()
|
||||
redmine.session.mount("https://", retry_adapter)
|
||||
redmine.session.mount("http://", retry_adapter)
|
||||
|
||||
return redmine
|
||||
|
||||
|
||||
def _fetch_issues_chunked(
|
||||
redmine: Redmine, issue_ids: List[int]
|
||||
) -> List[Issue]:
|
||||
"""Загружает задачи чанками, чтобы не превышать лимит длины URL (#21)."""
|
||||
all_issues: List[Issue] = []
|
||||
for i in range(0, len(issue_ids), ISSUE_ID_CHUNK_SIZE):
|
||||
chunk = issue_ids[i : i + ISSUE_ID_CHUNK_SIZE]
|
||||
issue_list_str = ",".join(str(x) for x in chunk)
|
||||
issues = redmine.issue.filter(
|
||||
issue_id=issue_list_str, status_id="*", sort="project:asc"
|
||||
)
|
||||
all_issues.extend(issues)
|
||||
return all_issues
|
||||
|
||||
|
||||
def fetch_issues_with_spent_time(
|
||||
from_date: str, to_date: str
|
||||
) -> Optional[List[Tuple[Issue, float]]]:
|
||||
@@ -26,11 +79,7 @@ def fetch_issues_with_spent_time(
|
||||
Returns list of (issue, total_hours) tuples.
|
||||
"""
|
||||
|
||||
redmine = Redmine(
|
||||
Config.get_redmine_url(),
|
||||
**_get_redmine_auth_kwargs(),
|
||||
requests={"verify": Config.get_redmine_verify()},
|
||||
)
|
||||
redmine = _create_redmine()
|
||||
|
||||
current_user = redmine.user.get("current")
|
||||
time_entries = redmine.time_entry.filter(
|
||||
@@ -49,9 +98,9 @@ def fetch_issues_with_spent_time(
|
||||
if not issue_ids:
|
||||
return None
|
||||
|
||||
# Загружаем полные объекты задач
|
||||
issue_list_str = ",".join(str(i) for i in issue_ids)
|
||||
issues = redmine.issue.filter(issue_id=issue_list_str, status_id="*", sort="project:asc")
|
||||
# Загружаем полные объекты задач чанками (#21)
|
||||
sorted_ids = sorted(issue_ids)
|
||||
issues = _fetch_issues_chunked(redmine, sorted_ids)
|
||||
|
||||
# Сопоставляем задачи с суммарным временем.
|
||||
# Сортировка выполняется в report_builder.build_grouped_report,
|
||||
|
||||
Reference in New Issue
Block a user