Skip to content

Commit 9a27429

Browse files
committed
feat(api): return SVG fallback cards for user/API errors
1 parent 1563a68 commit 9a27429

5 files changed

Lines changed: 218 additions & 23 deletions

File tree

‎api/main.py‎

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from config.settings import get_settings
77
from generators import stats_card, lang_card, contrib_card, recent_activity_card, trophy_card, streak_card, repo_card, social_card, badge_generator
88
from utils import github_api
9+
from utils.error_card import draw_error_card
910
from utils.cache import cache_svg_response, get_cache_stats, clear_cache
1011
from utils.validators import (
1112
validate_date,
@@ -45,7 +46,7 @@ def generate_cached_svg(generator_func, *args, **kwargs):
4546
return generator_func(*args, **kwargs)
4647

4748

48-
def svg_response(svg_content: str, request: Request):
49+
def svg_response(svg_content: str, request: Request, max_age: int = 14400):
4950
etag = hashlib.md5(svg_content.encode("utf-8")).hexdigest()
5051

5152
if request.headers.get("if-none-match") == etag:
@@ -55,7 +56,7 @@ def svg_response(svg_content: str, request: Request):
5556
content=svg_content,
5657
media_type="image/svg+xml",
5758
headers={
58-
"Cache-Control": "public, max-age=14400, s-maxage=14400",
59+
"Cache-Control": f"public, max-age={max_age}, s-maxage={max_age}",
5960
"ETag": etag,
6061
"Vary": "Accept-Encoding",
6162
# Security headers to prevent XSS
@@ -190,6 +191,22 @@ def parse_heatmap_colors(level_0, level_1, level_2, level_3, level_4):
190191

191192
return colors if colors else None
192193

194+
195+
def fetch_github_data_or_error_svg(request: Request, username: str, token: Optional[str] = None):
196+
"""Fetch GitHub profile data and return an SVG fallback response on API failures."""
197+
try:
198+
data = github_api.get_live_github_data(username, token, raise_errors=True)
199+
return data, None
200+
except github_api.UserNotFoundError as exc:
201+
svg_content = draw_error_card("user_not_found", username=username, message=exc.message)
202+
except github_api.RateLimitExceededError as exc:
203+
svg_content = draw_error_card("rate_limited", username=username, message=exc.message)
204+
except github_api.GitHubApiError as exc:
205+
svg_content = draw_error_card("api_error", username=username, message=exc.message)
206+
207+
# Keep error card cache shorter than normal cards so transient API issues recover quickly.
208+
return None, svg_response(svg_content, request, max_age=300)
209+
193210
@app.get("/api/stats")
194211
async def get_stats(
195212
request: Request,
@@ -212,8 +229,10 @@ async def get_stats(
212229

213230
# Get optional token from Authorization header for higher rate limits
214231
token = get_token_from_header(request)
215-
216-
data = github_api.get_live_github_data(username, token) or github_api.get_mock_data(username)
232+
233+
data, error_response = fetch_github_data_or_error_svg(request, username, token)
234+
if error_response:
235+
return error_response
217236

218237
show_options = {
219238
"stars": not hide_stars,
@@ -244,7 +263,10 @@ async def get_languages(
244263
username = validate_username(username)
245264
theme = validate_theme(theme)
246265

247-
data = github_api.get_live_github_data(username) or github_api.get_mock_data(username)
266+
token = get_token_from_header(request)
267+
data, error_response = fetch_github_data_or_error_svg(request, username, token)
268+
if error_response:
269+
return error_response
248270
custom_colors = parse_custom_overrides(bg_color, title_color, text_color, border_color, font)
249271

250272
# Parse exclude parameter into list of languages
@@ -279,7 +301,10 @@ async def get_contributions(
279301
start_date = validate_date(start_date)
280302
end_date = validate_date(end_date)
281303

282-
data = github_api.get_live_github_data(username) or github_api.get_mock_data(username)
304+
token = get_token_from_header(request)
305+
data, error_response = fetch_github_data_or_error_svg(request, username, token)
306+
if error_response:
307+
return error_response
283308
custom_colors = parse_custom_overrides(bg_color, title_color, text_color, border_color, font)
284309

285310
# Build date_range dict if dates are provided
@@ -320,8 +345,11 @@ async def get_calendar_heatmap(
320345
end_date = validate_date(end_date)
321346

322347
token = get_token_from_header(request)
323-
data = github_api.get_live_github_data(username, token) or github_api.get_mock_data(username)
324-
custom_colors = parse_colors(bg_color, title_color, text_color, border_color)
348+
data, error_response = fetch_github_data_or_error_svg(request, username, token)
349+
if error_response:
350+
return error_response
351+
352+
custom_colors = parse_custom_overrides(bg_color, title_color, text_color, border_color)
325353
heatmap_colors = parse_heatmap_colors(level_0, level_1, level_2, level_3, level_4)
326354

327355
date_range = None
@@ -384,7 +412,10 @@ async def get_trophy(
384412
username = validate_username(username)
385413
theme = validate_theme(theme)
386414

387-
data = github_api.get_live_github_data(username) or github_api.get_mock_data(username)
415+
token = get_token_from_header(request)
416+
data, error_response = fetch_github_data_or_error_svg(request, username, token)
417+
if error_response:
418+
return error_response
388419
custom_colors = parse_custom_overrides(bg_color, title_color, text_color, border_color, font)
389420
svg_content = trophy_card.draw_trophy_card(data, theme, custom_colors=custom_colors)
390421
return svg_response(svg_content, request)
@@ -405,7 +436,10 @@ async def get_streak(
405436
username = validate_username(username)
406437
theme = validate_theme(theme)
407438

408-
data = github_api.get_live_github_data(username) or github_api.get_mock_data(username)
439+
token = get_token_from_header(request)
440+
data, error_response = fetch_github_data_or_error_svg(request, username, token)
441+
if error_response:
442+
return error_response
409443
custom_colors = parse_custom_overrides(bg_color, title_color, text_color, border_color, font)
410444
svg_content = streak_card.draw_streak_card(data, theme, custom_colors=custom_colors)
411445
return svg_response(svg_content, request)
@@ -430,7 +464,10 @@ async def get_repos(
430464
sort_by = validate_sort_by(sort_by)
431465
limit = validate_limit(limit, min_val=1, max_val=10)
432466

433-
data = github_api.get_live_github_data(username) or github_api.get_mock_data(username)
467+
token = get_token_from_header(request)
468+
data, error_response = fetch_github_data_or_error_svg(request, username, token)
469+
if error_response:
470+
return error_response
434471
custom_colors = parse_custom_overrides(bg_color, title_color, text_color, border_color, font)
435472
svg_content = repo_card.draw_repo_card(data, theme, custom_colors=custom_colors, sort_by=sort_by, limit=limit)
436473
return svg_response(svg_content, request)

‎tests/test_api_error_fallback.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from fastapi.testclient import TestClient
2+
3+
from api.main import app
4+
from utils import github_api
5+
6+
7+
def test_stats_returns_user_not_found_svg(monkeypatch):
8+
def raise_not_found(*_args, **_kwargs):
9+
raise github_api.UserNotFoundError("missing-user")
10+
11+
monkeypatch.setattr(github_api, "get_live_github_data", raise_not_found)
12+
13+
with TestClient(app) as client:
14+
response = client.get("/api/stats", params={"username": "missing-user"})
15+
16+
assert response.status_code == 200
17+
assert response.headers["content-type"].startswith("image/svg+xml")
18+
assert "User Not Found" in response.text
19+
20+
21+
def test_languages_returns_rate_limited_svg(monkeypatch):
22+
def raise_rate_limited(*_args, **_kwargs):
23+
raise github_api.RateLimitExceededError("GitHub API rate limit exceeded")
24+
25+
monkeypatch.setattr(github_api, "get_live_github_data", raise_rate_limited)
26+
27+
with TestClient(app) as client:
28+
response = client.get("/api/languages", params={"username": "octocat"})
29+
30+
assert response.status_code == 200
31+
assert response.headers["content-type"].startswith("image/svg+xml")
32+
assert "Rate Limited" in response.text
33+
34+
35+
def test_repos_returns_api_error_svg(monkeypatch):
36+
def raise_api_error(*_args, **_kwargs):
37+
raise github_api.GitHubApiError("Service unavailable")
38+
39+
monkeypatch.setattr(github_api, "get_live_github_data", raise_api_error)
40+
41+
with TestClient(app) as client:
42+
response = client.get("/api/repos", params={"username": "octocat"})
43+
44+
assert response.status_code == 200
45+
assert response.headers["content-type"].startswith("image/svg+xml")
46+
assert "GitHub API Error" in response.text
47+
assert "Service unavailable" in response.text

‎tests/test_error_card.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,27 @@ def test_error_card_default_fallback():
2626
# passing unknown error_type should fall back gracefully
2727
result = draw_error_card("nonexistent_type", username="testuser")
2828
assert isinstance(result, str)
29-
assert "</svg>" in result
29+
assert "</svg>" in result
30+
31+
32+
def test_error_card_user_not_found_explicit():
33+
result = draw_error_card("user_not_found", username="ghost")
34+
assert isinstance(result, str)
35+
assert "</svg>" in result
36+
assert "User Not Found" in result
37+
assert "ghost" in result
38+
39+
40+
def test_error_card_rate_limited_explicit():
41+
result = draw_error_card("rate_limited", username="testuser")
42+
assert isinstance(result, str)
43+
assert "</svg>" in result
44+
assert "Rate Limited" in result
45+
46+
47+
def test_error_card_api_error_explicit():
48+
result = draw_error_card("api_error", username="testuser", message="Service unavailable")
49+
assert isinstance(result, str)
50+
assert "</svg>" in result
51+
assert "GitHub API Error" in result
52+
assert "Service unavailable" in result

‎utils/error_card.py‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@
77
def draw_error_card(error_type: str = "rate_limit", username: str = "user", message: str = "") -> str:
88
WIDTH, HEIGHT = 450, 160
99

10+
aliases = {
11+
"invalid_user": "user_not_found",
12+
"rate_limit": "rate_limited",
13+
"unknown": "api_error",
14+
}
15+
resolved_error_type = aliases.get(error_type, error_type)
16+
1017
config = {
11-
"rate_limit": {
18+
"rate_limited": {
1219
"icon": "⏳",
13-
"title": "API Rate Limit Reached",
20+
"title": "Rate Limited",
1421
"line1": "GitHub's API limit was hit for this session.",
1522
"line2": "Add a GITHUB_TOKEN in the sidebar or .env",
1623
"line3": "to increase the limit from 60 → 5,000 req/hr.",
@@ -19,22 +26,22 @@ def draw_error_card(error_type: str = "rate_limit", username: str = "user", mess
1926
"title_c": "#f0883e",
2027
"text_c": "#c9d1d9",
2128
},
22-
"invalid_user": {
29+
"user_not_found": {
2330
"icon": "❌",
24-
"title": f'User "{username}" Not Found',
31+
"title": "User Not Found",
2532
"line1": "No GitHub account exists for this username.",
26-
"line2": "Double-check the spelling and try again.",
33+
"line2": f'Username: "{username}"',
2734
"line3": "",
2835
"bg": "#161b22",
2936
"border": "#f85149",
3037
"title_c": "#f85149",
3138
"text_c": "#c9d1d9",
3239
},
33-
"unknown": {
40+
"api_error": {
3441
"icon": "⚠️",
35-
"title": "Could Not Load GitHub Data",
36-
"line1": message or "An unexpected error occurred.",
37-
"line2": "Check your internet connection and try again.",
42+
"title": "GitHub API Error",
43+
"line1": message or "Could not load GitHub profile data.",
44+
"line2": "Please try again in a few minutes.",
3845
"line3": "",
3946
"bg": "#161b22",
4047
"border": "#e3b341",
@@ -43,7 +50,7 @@ def draw_error_card(error_type: str = "rate_limit", username: str = "user", mess
4350
},
4451
}
4552

46-
c = config.get(error_type, config["unknown"])
53+
c = config.get(resolved_error_type, config["api_error"])
4754

4855
dwg = svgwrite.Drawing(size=(WIDTH, HEIGHT))
4956
dwg.viewbox(0, 0, WIDTH, HEIGHT)

0 commit comments

Comments
 (0)