megaplan-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- megaplan_sdk/__init__.py +67 -0
- megaplan_sdk/auth.py +185 -0
- megaplan_sdk/cache.py +192 -0
- megaplan_sdk/client.py +201 -0
- megaplan_sdk/constants.py +16 -0
- megaplan_sdk/exceptions.py +180 -0
- megaplan_sdk/helpers.py +108 -0
- megaplan_sdk/http_client.py +390 -0
- megaplan_sdk/logging_config.py +53 -0
- megaplan_sdk/models/__init__.py +22 -0
- megaplan_sdk/models/base.py +16 -0
- megaplan_sdk/models/comment.py +58 -0
- megaplan_sdk/models/common.py +107 -0
- megaplan_sdk/models/contractor.py +137 -0
- megaplan_sdk/models/deal.py +96 -0
- megaplan_sdk/models/department.py +40 -0
- megaplan_sdk/models/employee.py +117 -0
- megaplan_sdk/models/project.py +76 -0
- megaplan_sdk/models/task.py +75 -0
- megaplan_sdk/resources/__init__.py +15 -0
- megaplan_sdk/resources/auth.py +73 -0
- megaplan_sdk/resources/base.py +794 -0
- megaplan_sdk/resources/comments.py +148 -0
- megaplan_sdk/resources/contractors.py +173 -0
- megaplan_sdk/resources/deals.py +625 -0
- megaplan_sdk/resources/departments.py +70 -0
- megaplan_sdk/resources/employees.py +216 -0
- megaplan_sdk/resources/full_details.py +143 -0
- megaplan_sdk/resources/projects.py +854 -0
- megaplan_sdk/resources/tasks.py +932 -0
- megaplan_sdk/types.py +56 -0
- megaplan_sdk-0.1.0.dist-info/METADATA +1383 -0
- megaplan_sdk-0.1.0.dist-info/RECORD +36 -0
- megaplan_sdk-0.1.0.dist-info/WHEEL +5 -0
- megaplan_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- megaplan_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Custom exceptions for Megaplan SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MegaplanError(Exception):
|
|
7
|
+
"""Base exception for all Megaplan SDK errors.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
message: Human-readable error message.
|
|
11
|
+
status_code: HTTP status code if available.
|
|
12
|
+
errors: List of error details from API response.
|
|
13
|
+
response: Full API response if available.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
status_code: int | None = None,
|
|
20
|
+
errors: list[dict[str, Any]] | None = None,
|
|
21
|
+
response: dict[str, Any] | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Initialize MegaplanError.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
message: Error message.
|
|
27
|
+
status_code: HTTP status code.
|
|
28
|
+
errors: List of error details.
|
|
29
|
+
response: Full API response.
|
|
30
|
+
"""
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.message = message
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.errors = errors or []
|
|
35
|
+
self.response = response
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
"""Return string representation of error."""
|
|
39
|
+
if self.status_code:
|
|
40
|
+
return f"{self.message} (HTTP {self.status_code})"
|
|
41
|
+
return self.message
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthenticationError(MegaplanError):
|
|
45
|
+
"""Raised when authentication fails (401).
|
|
46
|
+
|
|
47
|
+
Typically occurs when credentials are invalid or token has expired.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
message: str = "Authentication failed",
|
|
53
|
+
errors: list[dict[str, Any]] | None = None,
|
|
54
|
+
response: dict[str, Any] | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Initialize AuthenticationError."""
|
|
57
|
+
super().__init__(message, status_code=401, errors=errors, response=response)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AuthorizationError(MegaplanError):
|
|
61
|
+
"""Raised when authorization fails (403).
|
|
62
|
+
|
|
63
|
+
Occurs when user doesn't have permission to access the resource.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
message: str = "Authorization failed",
|
|
69
|
+
errors: list[dict[str, Any]] | None = None,
|
|
70
|
+
response: dict[str, Any] | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Initialize AuthorizationError."""
|
|
73
|
+
super().__init__(message, status_code=403, errors=errors, response=response)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class NotFoundError(MegaplanError):
|
|
77
|
+
"""Raised when resource is not found (404)."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
message: str = "Resource not found",
|
|
82
|
+
errors: list[dict[str, Any]] | None = None,
|
|
83
|
+
response: dict[str, Any] | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Initialize NotFoundError."""
|
|
86
|
+
super().__init__(message, status_code=404, errors=errors, response=response)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ValidationError(MegaplanError):
|
|
90
|
+
"""Raised when request validation fails (422).
|
|
91
|
+
|
|
92
|
+
Contains detailed validation errors from API.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
message: str = "Validation failed",
|
|
98
|
+
errors: list[dict[str, Any]] | None = None,
|
|
99
|
+
response: dict[str, Any] | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Initialize ValidationError."""
|
|
102
|
+
super().__init__(message, status_code=422, errors=errors, response=response)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RateLimitError(MegaplanError):
|
|
106
|
+
"""Raised when rate limit is exceeded (429)."""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
message: str = "Rate limit exceeded",
|
|
111
|
+
errors: list[dict[str, Any]] | None = None,
|
|
112
|
+
response: dict[str, Any] | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Initialize RateLimitError."""
|
|
115
|
+
super().__init__(message, status_code=429, errors=errors, response=response)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ServerError(MegaplanError):
|
|
119
|
+
"""Raised when server returns 5xx error."""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
message: str = "Server error",
|
|
124
|
+
status_code: int = 500,
|
|
125
|
+
errors: list[dict[str, Any]] | None = None,
|
|
126
|
+
response: dict[str, Any] | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Initialize ServerError."""
|
|
129
|
+
super().__init__(message, status_code=status_code, errors=errors, response=response)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def raise_for_status(
|
|
133
|
+
status_code: int,
|
|
134
|
+
response: dict[str, Any],
|
|
135
|
+
default_message: str = "Request failed",
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Raise appropriate exception based on HTTP status code.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
status_code: HTTP status code.
|
|
141
|
+
response: API response dictionary.
|
|
142
|
+
default_message: Default error message.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
AuthenticationError: For 401 status.
|
|
146
|
+
AuthorizationError: For 403 status.
|
|
147
|
+
NotFoundError: For 404 status.
|
|
148
|
+
ValidationError: For 422 status.
|
|
149
|
+
RateLimitError: For 429 status.
|
|
150
|
+
ServerError: For 5xx status.
|
|
151
|
+
MegaplanError: For other error status codes.
|
|
152
|
+
"""
|
|
153
|
+
meta = response.get("meta", {})
|
|
154
|
+
errors = meta.get("errors", [])
|
|
155
|
+
error_message = default_message
|
|
156
|
+
|
|
157
|
+
if errors and isinstance(errors, list) and len(errors) > 0:
|
|
158
|
+
first_error = errors[0]
|
|
159
|
+
if isinstance(first_error, dict) and "message" in first_error:
|
|
160
|
+
error_message = first_error["message"]
|
|
161
|
+
|
|
162
|
+
if status_code == 401:
|
|
163
|
+
raise AuthenticationError(error_message, errors=errors, response=response)
|
|
164
|
+
elif status_code == 403:
|
|
165
|
+
raise AuthorizationError(error_message, errors=errors, response=response)
|
|
166
|
+
elif status_code == 404:
|
|
167
|
+
raise NotFoundError(error_message, errors=errors, response=response)
|
|
168
|
+
elif status_code == 422:
|
|
169
|
+
raise ValidationError(error_message, errors=errors, response=response)
|
|
170
|
+
elif status_code == 429:
|
|
171
|
+
raise RateLimitError(error_message, errors=errors, response=response)
|
|
172
|
+
elif 500 <= status_code < 600:
|
|
173
|
+
raise ServerError(error_message, status_code=status_code, errors=errors, response=response)
|
|
174
|
+
else:
|
|
175
|
+
raise MegaplanError(
|
|
176
|
+
error_message,
|
|
177
|
+
status_code=status_code,
|
|
178
|
+
errors=errors,
|
|
179
|
+
response=response,
|
|
180
|
+
)
|
megaplan_sdk/helpers.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Helper functions for working with Megaplan SDK.
|
|
2
|
+
|
|
3
|
+
Provides convenience functions for creating BaseEntity objects and simplifying
|
|
4
|
+
common operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from megaplan_sdk.constants import ContentType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_entity(content_type: str, entity_id: int) -> dict[str, Any]:
|
|
13
|
+
"""Create a BaseEntity reference dictionary.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
content_type: Entity content type (e.g., "Employee", "Project", "Task").
|
|
17
|
+
entity_id: Entity identifier.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Dictionary representing a BaseEntity reference.
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
>>> make_entity("Employee", 123)
|
|
24
|
+
{"contentType": "Employee", "id": 123}
|
|
25
|
+
>>> make_entity("Project", 456)
|
|
26
|
+
{"contentType": "Project", "id": 456}
|
|
27
|
+
"""
|
|
28
|
+
return {"contentType": content_type, "id": entity_id}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_employee_entity(employee_id: int) -> dict[str, Any]:
|
|
32
|
+
"""Create an Employee BaseEntity reference.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
employee_id: Employee identifier.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Dictionary representing an Employee reference.
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
>>> make_employee_entity(123)
|
|
42
|
+
{"contentType": "Employee", "id": 123}
|
|
43
|
+
"""
|
|
44
|
+
return make_entity(ContentType.EMPLOYEE, employee_id)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def make_project_entity(project_id: int) -> dict[str, Any]:
|
|
48
|
+
"""Create a Project BaseEntity reference.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
project_id: Project identifier.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Dictionary representing a Project reference.
|
|
55
|
+
|
|
56
|
+
Examples:
|
|
57
|
+
>>> make_project_entity(456)
|
|
58
|
+
{"contentType": "Project", "id": 456}
|
|
59
|
+
"""
|
|
60
|
+
return make_entity(ContentType.PROJECT, project_id)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def make_task_entity(task_id: int) -> dict[str, Any]:
|
|
64
|
+
"""Create a Task BaseEntity reference.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
task_id: Task identifier.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Dictionary representing a Task reference.
|
|
71
|
+
|
|
72
|
+
Examples:
|
|
73
|
+
>>> make_task_entity(789)
|
|
74
|
+
{"contentType": "Task", "id": 789}
|
|
75
|
+
"""
|
|
76
|
+
return make_entity(ContentType.TASK, task_id)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def make_deal_entity(deal_id: int) -> dict[str, Any]:
|
|
80
|
+
"""Create a Deal BaseEntity reference.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
deal_id: Deal identifier.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Dictionary representing a Deal reference.
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
>>> make_deal_entity(101)
|
|
90
|
+
{"contentType": "Deal", "id": 101}
|
|
91
|
+
"""
|
|
92
|
+
return make_entity(ContentType.DEAL, deal_id)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def make_contractor_entity(contractor_id: int) -> dict[str, Any]:
|
|
96
|
+
"""Create a Contractor BaseEntity reference.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
contractor_id: Contractor identifier.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Dictionary representing a Contractor reference.
|
|
103
|
+
|
|
104
|
+
Examples:
|
|
105
|
+
>>> make_contractor_entity(202)
|
|
106
|
+
{"contentType": "Contractor", "id": 202}
|
|
107
|
+
"""
|
|
108
|
+
return make_entity(ContentType.CONTRACTOR, contractor_id)
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""HTTP client for Megaplan API."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from megaplan_sdk.exceptions import raise_for_status
|
|
10
|
+
from megaplan_sdk.logging_config import logger, sanitize_dict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HTTPClient:
|
|
14
|
+
"""HTTP client with authentication, retry logic, and response validation.
|
|
15
|
+
|
|
16
|
+
Handles:
|
|
17
|
+
- Automatic access token injection
|
|
18
|
+
- JSON parameters in query string
|
|
19
|
+
- Retry logic with exponential backoff
|
|
20
|
+
- Response validation
|
|
21
|
+
- Error handling
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
base_url: str,
|
|
27
|
+
access_token: str | None = None,
|
|
28
|
+
timeout: float = 30.0,
|
|
29
|
+
max_retries: int = 3,
|
|
30
|
+
allow_http: bool = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize HTTP client.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
base_url: Base URL for Megaplan API (e.g., https://example.megaplan.ru).
|
|
36
|
+
access_token: Optional access token for authentication.
|
|
37
|
+
timeout: Request timeout in seconds.
|
|
38
|
+
max_retries: Maximum number of retry attempts for 5xx errors.
|
|
39
|
+
allow_http: Allow HTTP connections (insecure, only for dev/test).
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If base_url is not HTTPS and allow_http is False.
|
|
43
|
+
"""
|
|
44
|
+
# Security: Validate HTTPS URL
|
|
45
|
+
if not base_url.startswith("https://") and not allow_http:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Only HTTPS URLs are allowed for security. Got: {base_url}. "
|
|
48
|
+
f"Use allow_http=True only for development/testing."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
self.base_url = base_url.rstrip("/")
|
|
52
|
+
self.access_token = access_token
|
|
53
|
+
self.timeout = timeout
|
|
54
|
+
self.max_retries = max_retries
|
|
55
|
+
self._client: httpx.AsyncClient | None = None
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self) -> "HTTPClient":
|
|
58
|
+
"""Async context manager entry."""
|
|
59
|
+
await self._ensure_client()
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
63
|
+
"""Async context manager exit."""
|
|
64
|
+
await self.close()
|
|
65
|
+
|
|
66
|
+
async def _ensure_client(self) -> None:
|
|
67
|
+
"""Ensure HTTP client is initialized."""
|
|
68
|
+
if self._client is None:
|
|
69
|
+
# Configure connection pooling for better performance
|
|
70
|
+
limits = httpx.Limits(
|
|
71
|
+
max_connections=100, # Maximum total connections
|
|
72
|
+
max_keepalive_connections=20, # Keep 20 connections alive
|
|
73
|
+
keepalive_expiry=30.0, # Keep connections alive for 30 seconds
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self._client = httpx.AsyncClient(
|
|
77
|
+
base_url=self.base_url,
|
|
78
|
+
timeout=self.timeout,
|
|
79
|
+
headers={"Content-Type": "application/json"},
|
|
80
|
+
limits=limits,
|
|
81
|
+
follow_redirects=True, # Follow redirects automatically
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def close(self) -> None:
|
|
85
|
+
"""Close HTTP client."""
|
|
86
|
+
if self._client is not None:
|
|
87
|
+
await self._client.aclose()
|
|
88
|
+
self._client = None
|
|
89
|
+
|
|
90
|
+
def set_access_token(self, access_token: str | None) -> None:
|
|
91
|
+
"""Set access token for authentication.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
access_token: OAuth2 access token (or None to clear).
|
|
95
|
+
"""
|
|
96
|
+
self.access_token = access_token
|
|
97
|
+
|
|
98
|
+
def _build_url(self, path: str, params: dict[str, Any] | None = None) -> str:
|
|
99
|
+
"""Build URL with JSON parameters in query string.
|
|
100
|
+
|
|
101
|
+
Megaplan API expects JSON parameters in query string format:
|
|
102
|
+
/api/v3/task?{"limit":5}
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
path: API path (e.g., /api/v3/task).
|
|
106
|
+
params: Query parameters as dictionary.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Full URL with query string.
|
|
110
|
+
"""
|
|
111
|
+
url = f"{self.base_url}{path}"
|
|
112
|
+
|
|
113
|
+
if params:
|
|
114
|
+
params_json = json.dumps(params, ensure_ascii=False)
|
|
115
|
+
url = f"{url}?{params_json}"
|
|
116
|
+
|
|
117
|
+
return url
|
|
118
|
+
|
|
119
|
+
def _build_headers(self, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
|
|
120
|
+
"""Build request headers with authentication.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
extra_headers: Additional headers to include.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Headers dictionary.
|
|
127
|
+
"""
|
|
128
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
129
|
+
|
|
130
|
+
if self.access_token:
|
|
131
|
+
headers["Authorization"] = f"Bearer {self.access_token}"
|
|
132
|
+
|
|
133
|
+
if extra_headers:
|
|
134
|
+
headers.update(extra_headers)
|
|
135
|
+
|
|
136
|
+
return headers
|
|
137
|
+
|
|
138
|
+
async def _request(
|
|
139
|
+
self,
|
|
140
|
+
method: str,
|
|
141
|
+
path: str,
|
|
142
|
+
params: dict[str, Any] | None = None,
|
|
143
|
+
json_data: dict[str, Any] | None = None,
|
|
144
|
+
files: dict[str, Any] | None = None,
|
|
145
|
+
headers: dict[str, str] | None = None,
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
"""Make HTTP request with retry logic.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
method: HTTP method (GET, POST, DELETE, etc.).
|
|
151
|
+
path: API path.
|
|
152
|
+
params: Query parameters.
|
|
153
|
+
json_data: JSON body data.
|
|
154
|
+
files: Files for multipart/form-data.
|
|
155
|
+
headers: Additional headers.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Response JSON as dictionary.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
MegaplanError: For various error conditions.
|
|
162
|
+
"""
|
|
163
|
+
await self._ensure_client()
|
|
164
|
+
assert self._client is not None # For mypy: ensured by _ensure_client()
|
|
165
|
+
|
|
166
|
+
url = self._build_url(path, params)
|
|
167
|
+
request_headers = self._build_headers(headers)
|
|
168
|
+
|
|
169
|
+
if files:
|
|
170
|
+
request_headers.pop("Content-Type", None)
|
|
171
|
+
|
|
172
|
+
for attempt in range(self.max_retries + 1):
|
|
173
|
+
try:
|
|
174
|
+
# LOG REQUEST
|
|
175
|
+
logger.debug(
|
|
176
|
+
f"Making {method} request to {path}",
|
|
177
|
+
extra={
|
|
178
|
+
"method": method,
|
|
179
|
+
"path": path,
|
|
180
|
+
"params": sanitize_dict(params) if params else None,
|
|
181
|
+
"attempt": attempt + 1,
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
response = await self._client.request(
|
|
186
|
+
method=method,
|
|
187
|
+
url=url,
|
|
188
|
+
headers=request_headers,
|
|
189
|
+
json=json_data,
|
|
190
|
+
files=files,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
response.raise_for_status()
|
|
194
|
+
response_data: dict[str, Any] = response.json()
|
|
195
|
+
|
|
196
|
+
meta = response_data.get("meta", {})
|
|
197
|
+
status = meta.get("status", response.status_code)
|
|
198
|
+
|
|
199
|
+
if status != 200:
|
|
200
|
+
raise_for_status(status, response_data)
|
|
201
|
+
|
|
202
|
+
# LOG SUCCESS
|
|
203
|
+
logger.debug(
|
|
204
|
+
f"{method} {path} succeeded",
|
|
205
|
+
extra={"status_code": response.status_code, "attempt": attempt + 1},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return response_data
|
|
209
|
+
|
|
210
|
+
except httpx.HTTPStatusError as e:
|
|
211
|
+
status_code = e.response.status_code
|
|
212
|
+
|
|
213
|
+
# LOG HTTP ERROR
|
|
214
|
+
logger.warning(
|
|
215
|
+
f"HTTP error {status_code} on {method} {path}",
|
|
216
|
+
extra={"status_code": status_code, "attempt": attempt + 1},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Handle 429 Rate Limit
|
|
220
|
+
if status_code == 429:
|
|
221
|
+
retry_after = e.response.headers.get("Retry-After", "60")
|
|
222
|
+
try:
|
|
223
|
+
wait_time = int(retry_after)
|
|
224
|
+
except ValueError:
|
|
225
|
+
# Retry-After might be a date string, default to 60s
|
|
226
|
+
wait_time = 60
|
|
227
|
+
|
|
228
|
+
logger.warning(
|
|
229
|
+
f"Rate limit exceeded (429). Retry after {wait_time}s",
|
|
230
|
+
extra={"wait_time": wait_time, "attempt": attempt + 1},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if attempt < self.max_retries:
|
|
234
|
+
await asyncio.sleep(wait_time)
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
# Handle 5xx Server Errors
|
|
238
|
+
if 500 <= status_code < 600 and attempt < self.max_retries:
|
|
239
|
+
# Check Retry-After header
|
|
240
|
+
retry_after_header = e.response.headers.get("Retry-After")
|
|
241
|
+
if retry_after_header:
|
|
242
|
+
try:
|
|
243
|
+
wait_time = int(retry_after_header)
|
|
244
|
+
except ValueError:
|
|
245
|
+
wait_time = 2**attempt
|
|
246
|
+
else:
|
|
247
|
+
wait_time = 2**attempt
|
|
248
|
+
|
|
249
|
+
logger.info(
|
|
250
|
+
f"Retrying after {wait_time}s (attempt {attempt + 1}/{self.max_retries})",
|
|
251
|
+
extra={"wait_time": wait_time, "attempt": attempt + 1},
|
|
252
|
+
)
|
|
253
|
+
await asyncio.sleep(wait_time)
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
# Parse error response
|
|
257
|
+
response_data = {}
|
|
258
|
+
if e.response:
|
|
259
|
+
try:
|
|
260
|
+
response_data = e.response.json()
|
|
261
|
+
except json.JSONDecodeError:
|
|
262
|
+
logger.warning("Failed to parse error response as JSON")
|
|
263
|
+
response_data = {"meta": {"errors": [{"message": e.response.text[:200]}]}}
|
|
264
|
+
|
|
265
|
+
raise_for_status(status_code, response_data)
|
|
266
|
+
|
|
267
|
+
except httpx.RequestError as e:
|
|
268
|
+
# LOG REQUEST ERROR
|
|
269
|
+
logger.warning(
|
|
270
|
+
f"Request error on {method} {path}: {str(e)}",
|
|
271
|
+
extra={"error_type": type(e).__name__, "attempt": attempt + 1},
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if attempt < self.max_retries:
|
|
275
|
+
wait_time = 2**attempt
|
|
276
|
+
logger.info(
|
|
277
|
+
f"Retrying after {wait_time}s (attempt {attempt + 1}/{self.max_retries})",
|
|
278
|
+
extra={"wait_time": wait_time, "attempt": attempt + 1},
|
|
279
|
+
)
|
|
280
|
+
await asyncio.sleep(wait_time)
|
|
281
|
+
continue
|
|
282
|
+
raise
|
|
283
|
+
|
|
284
|
+
# Note: This point is never reached because:
|
|
285
|
+
# - Success case returns via 'return response_data'
|
|
286
|
+
# - Error cases either raise immediately or continue retrying
|
|
287
|
+
# - After max retries, exceptions are re-raised above
|
|
288
|
+
raise RuntimeError("Unexpected error in request")
|
|
289
|
+
|
|
290
|
+
async def get(
|
|
291
|
+
self,
|
|
292
|
+
path: str,
|
|
293
|
+
params: dict[str, Any] | None = None,
|
|
294
|
+
headers: dict[str, str] | None = None,
|
|
295
|
+
) -> dict[str, Any]:
|
|
296
|
+
"""Make GET request.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
path: API path.
|
|
300
|
+
params: Query parameters.
|
|
301
|
+
headers: Additional headers.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Response JSON as dictionary.
|
|
305
|
+
"""
|
|
306
|
+
return await self._request("GET", path, params=params, headers=headers)
|
|
307
|
+
|
|
308
|
+
async def post(
|
|
309
|
+
self,
|
|
310
|
+
path: str,
|
|
311
|
+
json_data: dict[str, Any] | None = None,
|
|
312
|
+
files: dict[str, Any] | None = None,
|
|
313
|
+
params: dict[str, Any] | None = None,
|
|
314
|
+
headers: dict[str, str] | None = None,
|
|
315
|
+
) -> dict[str, Any]:
|
|
316
|
+
"""Make POST request.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
path: API path.
|
|
320
|
+
json_data: JSON body data.
|
|
321
|
+
files: Files for multipart/form-data.
|
|
322
|
+
params: Query parameters.
|
|
323
|
+
headers: Additional headers.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Response JSON as dictionary.
|
|
327
|
+
"""
|
|
328
|
+
return await self._request(
|
|
329
|
+
"POST", path, json_data=json_data, files=files, params=params, headers=headers
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
async def put(
|
|
333
|
+
self,
|
|
334
|
+
path: str,
|
|
335
|
+
json_data: dict[str, Any] | None = None,
|
|
336
|
+
params: dict[str, Any] | None = None,
|
|
337
|
+
headers: dict[str, str] | None = None,
|
|
338
|
+
) -> dict[str, Any]:
|
|
339
|
+
"""Make PUT request.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
path: API path.
|
|
343
|
+
json_data: JSON body data.
|
|
344
|
+
params: Query parameters.
|
|
345
|
+
headers: Additional headers.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Response JSON as dictionary.
|
|
349
|
+
"""
|
|
350
|
+
return await self._request("PUT", path, json_data=json_data, params=params, headers=headers)
|
|
351
|
+
|
|
352
|
+
async def delete(
|
|
353
|
+
self,
|
|
354
|
+
path: str,
|
|
355
|
+
params: dict[str, Any] | None = None,
|
|
356
|
+
headers: dict[str, str] | None = None,
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
"""Make DELETE request.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
path: API path.
|
|
362
|
+
params: Query parameters.
|
|
363
|
+
headers: Additional headers.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
Response JSON as dictionary.
|
|
367
|
+
"""
|
|
368
|
+
return await self._request("DELETE", path, params=params, headers=headers)
|
|
369
|
+
|
|
370
|
+
async def post_form(
|
|
371
|
+
self,
|
|
372
|
+
url: str,
|
|
373
|
+
data: dict[str, Any],
|
|
374
|
+
headers: dict[str, str] | None = None,
|
|
375
|
+
) -> httpx.Response:
|
|
376
|
+
"""Make POST request with form data (for auth).
|
|
377
|
+
|
|
378
|
+
This is a public method for authentication that doesn't require a token.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
url: Full URL (not just path).
|
|
382
|
+
data: Form data dictionary.
|
|
383
|
+
headers: Optional headers.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Raw httpx Response object.
|
|
387
|
+
"""
|
|
388
|
+
await self._ensure_client()
|
|
389
|
+
assert self._client is not None # For mypy: ensured by _ensure_client()
|
|
390
|
+
return await self._client.post(url, data=data, headers=headers)
|