|
1 | 1 | """API Helpers module""" |
2 | 2 |
|
3 | | -import traceback |
4 | | -from datetime import UTC, datetime |
5 | 3 | from functools import cache |
6 | | -from typing import TYPE_CHECKING, Any |
| 4 | +from typing import TYPE_CHECKING |
7 | 5 |
|
8 | | -import httpx |
9 | | -from fastapi import HTTPException, status |
| 6 | +from fastapi import status |
10 | 7 |
|
11 | 8 | from app.api.models.errors import ( |
12 | 9 | BlizzardErrorMessage, |
|
15 | 12 | RateLimitErrorMessage, |
16 | 13 | ) |
17 | 14 | from app.config import settings |
18 | | -from app.infrastructure.decorators import rate_limited |
19 | | -from app.infrastructure.logger import logger |
20 | 15 |
|
21 | 16 | if TYPE_CHECKING: |
22 | 17 | from fastapi import Request, Response |
|
112 | 107 | } |
113 | 108 |
|
114 | 109 |
|
115 | | -def overfast_internal_error(url: str, error: Exception) -> HTTPException: |
116 | | - """Returns an Internal Server Error. Also log it and eventually send |
117 | | - a Discord notification via a webhook if configured. |
118 | | - """ |
119 | | - |
120 | | - # Get error details |
121 | | - error_str = str(error) |
122 | | - error_type = type(error).__name__ |
123 | | - |
124 | | - # Log the critical error with full traceback |
125 | | - logger.critical( |
126 | | - "Internal server error for URL {} : {}\n{}", |
127 | | - url, |
128 | | - error_str, |
129 | | - traceback.format_exc(), |
130 | | - ) |
131 | | - |
132 | | - # If we're using a profiler, it means we're debugging, raise the error |
133 | | - # directly in order to have proper backtrace in logs |
134 | | - if settings.profiler: |
135 | | - raise error # pragma: no cover |
136 | | - |
137 | | - # Truncate error message for Discord (keep first part which is most relevant) |
138 | | - max_error_length = 900 # Field value limit is 1024, leave room for formatting |
139 | | - if len(error_str) > max_error_length: |
140 | | - # For validation errors, try to show just the summary |
141 | | - if "validation error" in error_str.lower(): |
142 | | - lines = error_str.split("\n") |
143 | | - error_str = "\n".join( |
144 | | - lines[:5] |
145 | | - ) # First 5 lines usually contain the key info |
146 | | - if len(error_str) > max_error_length: |
147 | | - error_str = error_str[:max_error_length] |
148 | | - else: |
149 | | - error_str = error_str[:max_error_length] |
150 | | - |
151 | | - # Send a message to the given channel using Discord Webhook URL |
152 | | - send_discord_webhook_message( |
153 | | - title="🚨 Internal Server Error", |
154 | | - url=f"{settings.app_base_url}{url}" if not url.startswith("http") else url, |
155 | | - fields=[ |
156 | | - {"name": "Error Type", "value": f"`{error_type}`", "inline": True}, |
157 | | - {"name": "Endpoint", "value": f"`{url}`", "inline": True}, |
158 | | - { |
159 | | - "name": "Error Message", |
160 | | - "value": f"```\n{error_str}\n```", |
161 | | - "inline": False, |
162 | | - }, |
163 | | - ], |
164 | | - color=0xE74C3C, # Red |
165 | | - ) |
166 | | - |
167 | | - return HTTPException( |
168 | | - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
169 | | - detail=settings.internal_server_error_message, |
170 | | - ) |
171 | | - |
172 | | - |
173 | | -def _truncate_text(text: str, max_length: int, suffix: str = "...") -> str: |
174 | | - """Truncate text to max length with suffix if needed.""" |
175 | | - if len(text) <= max_length: |
176 | | - return text |
177 | | - return text[: max_length - len(suffix)] + suffix |
178 | | - |
179 | | - |
180 | | -def _truncate_embed_fields( |
181 | | - fields: list[dict[str, Any]], |
182 | | -) -> list[dict[str, Any]]: |
183 | | - """Truncate field names and values to Discord limits.""" |
184 | | - max_field_name_length = 250 # Actual limit: 256 |
185 | | - max_field_value_length = 1000 # Actual limit: 1024 |
186 | | - |
187 | | - for field in fields: |
188 | | - name = field.get("name", "") |
189 | | - value = field.get("value", "") |
190 | | - |
191 | | - if isinstance(name, str) and len(name) > max_field_name_length: |
192 | | - field["name"] = _truncate_text(name, max_field_name_length) |
193 | | - if isinstance(value, str) and len(value) > max_field_value_length: |
194 | | - field["value"] = _truncate_text( |
195 | | - value, max_field_value_length, "\n*(truncated)*" |
196 | | - ) |
197 | | - |
198 | | - return fields |
199 | | - |
200 | | - |
201 | | -@rate_limited(max_calls=1, interval=1800) |
202 | | -def send_discord_webhook_message( |
203 | | - *, |
204 | | - title: str | None = None, |
205 | | - description: str | None = None, |
206 | | - url: str | None = None, |
207 | | - fields: list[dict[str, Any]] | None = None, |
208 | | - color: int | None = None, |
209 | | -) -> httpx.Response | None: |
210 | | - """Helper method for sending a Discord webhook message using modern embed syntax. |
211 | | - It's limited to one call per 30 minutes with the same parameters. |
212 | | -
|
213 | | - Args: |
214 | | - title: Optional title for the embed (max 256 chars) |
215 | | - description: Optional description text (max 4096 chars) |
216 | | - url: Optional URL to make the title clickable |
217 | | - fields: Optional list of field dicts with 'name', 'value', and optional 'inline' keys |
218 | | - color: Optional color for the embed (decimal format, e.g., 0xFF0000 for red) |
219 | | - """ |
220 | | - if not settings.discord_webhook_enabled: |
221 | | - logger.error("{}: {}", title, description) |
222 | | - return None |
223 | | - |
224 | | - # Apply Discord embed length limits |
225 | | - if title: |
226 | | - title = _truncate_text(title, 250) |
227 | | - if description: |
228 | | - description = _truncate_text(description, 4000, "\n\n*(truncated)*") |
229 | | - if fields: |
230 | | - fields = _truncate_embed_fields(fields) |
231 | | - |
232 | | - # Build the embed payload |
233 | | - embed = { |
234 | | - "color": color or 0xE74C3C, # Default to red for errors/alerts |
235 | | - "timestamp": datetime.now(UTC).isoformat(), |
236 | | - } |
237 | | - |
238 | | - if title: |
239 | | - embed["title"] = title |
240 | | - if description: |
241 | | - embed["description"] = description |
242 | | - if url: |
243 | | - embed["url"] = url |
244 | | - if fields: |
245 | | - embed["fields"] = fields |
246 | | - |
247 | | - payload = {"username": "OverFast API", "embeds": [embed]} |
248 | | - |
249 | | - return httpx.post( # pragma: no cover |
250 | | - settings.discord_webhook_url, json=payload, timeout=10 |
251 | | - ) |
252 | | - |
253 | | - |
254 | 110 | @cache |
255 | 111 | def get_human_readable_duration(duration: int) -> str: |
256 | 112 | # Define the time units |
|
0 commit comments