@@ -70,14 +70,23 @@ public RetroAchievementsService( |
70 | 70 | if (!response.IsSuccessStatusCode) return null; |
71 | 71 |
|
72 | 72 | var jsonString = await response.Content.ReadAsStringAsync(); |
73 | | - using var doc = JsonDocument.Parse(jsonString); |
| 73 | + if (!TryGetJsonPayload(jsonString, nameof(GetSessionTokenAsync), out var json)) return null; |
| 74 | + |
| 75 | + using var doc = JsonDocument.Parse(json); |
74 | 76 | var root = doc.RootElement; |
75 | 77 |
|
76 | 78 | return root.TryGetProperty("Success", out var success) && success.GetBoolean() && |
77 | 79 | root.TryGetProperty("Token", out var token) |
78 | 80 | ? token.GetString() |
79 | 81 | : null; |
80 | 82 | } |
| 83 | + catch (JsonException ex) |
| 84 | + { |
| 85 | + // Expected external condition (malformed/non-JSON API payload): not a bug. |
| 86 | + _logger.Information(ex, |
| 87 | + "[RA Service] Invalid JSON response in GetSessionTokenAsync; the API may be temporarily unavailable."); |
| 88 | + return null; |
| 89 | + } |
81 | 90 | catch (Exception ex) |
82 | 91 | { |
83 | 92 | _logger.Error(ex, "[RA Service] Failed to get session token"); |
@@ -121,7 +130,7 @@ public RetroAchievementsService( |
121 | 130 | } |
122 | 131 |
|
123 | 132 | var json = await response.Content.ReadAsStringAsync(); |
124 | | - var apiResponse = JsonSerializer.Deserialize<RaGameProgressResponse>(json); |
| 133 | + var apiResponse = DeserializeApiResponse<RaGameProgressResponse>(json, nameof(GetGameInfoAndUserProgressAsync)); |
125 | 134 |
|
126 | 135 | if (apiResponse == null) return (null, null); |
127 | 136 |
|
@@ -208,7 +217,7 @@ public RetroAchievementsService( |
208 | 217 | } |
209 | 218 |
|
210 | 219 | var json = await response.Content.ReadAsStringAsync(); |
211 | | - return JsonSerializer.Deserialize<RaGameExtendedDetails>(json); |
| 220 | + return DeserializeApiResponse<RaGameExtendedDetails>(json, nameof(GetGameExtendedAsync)); |
212 | 221 | } |
213 | 222 | catch (RaUnauthorizedException) |
214 | 223 | { |
@@ -242,7 +251,7 @@ public RetroAchievementsService( |
242 | 251 | } |
243 | 252 |
|
244 | 253 | var json = await response.Content.ReadAsStringAsync(); |
245 | | - return JsonSerializer.Deserialize<List<RaUserGameRank>>(json); |
| 254 | + return DeserializeApiResponse<List<RaUserGameRank>>(json, nameof(GetUserGameRankAndScoreAsync)); |
246 | 255 | } |
247 | 256 | catch (RaUnauthorizedException) |
248 | 257 | { |
@@ -278,7 +287,7 @@ public RetroAchievementsService( |
278 | 287 | } |
279 | 288 |
|
280 | 289 | var json = await response.Content.ReadAsStringAsync(); |
281 | | - return JsonSerializer.Deserialize<List<RaGameRankAndScore>>(json); |
| 290 | + return DeserializeApiResponse<List<RaGameRankAndScore>>(json, nameof(GetGameRankAndScoreAsync)); |
282 | 291 | } |
283 | 292 | catch (RaUnauthorizedException) |
284 | 293 | { |
@@ -312,7 +321,7 @@ public RetroAchievementsService( |
312 | 321 | } |
313 | 322 |
|
314 | 323 | var json = await response.Content.ReadAsStringAsync(); |
315 | | - return JsonSerializer.Deserialize<RaProfile>(json); |
| 324 | + return DeserializeApiResponse<RaProfile>(json, nameof(GetUserProfileAsync)); |
316 | 325 | } |
317 | 326 | catch (RaUnauthorizedException) |
318 | 327 | { |
@@ -360,7 +369,7 @@ public RetroAchievementsService( |
360 | 369 | } |
361 | 370 |
|
362 | 371 | var json = await response.Content.ReadAsStringAsync(); |
363 | | - return JsonSerializer.Deserialize<List<RaRecentlyPlayedGame>>(json); |
| 372 | + return DeserializeApiResponse<List<RaRecentlyPlayedGame>>(json, nameof(GetUserRecentlyPlayedGamesAsync)); |
364 | 373 | } |
365 | 374 | catch (RaUnauthorizedException) |
366 | 375 | { |
@@ -398,7 +407,7 @@ public RetroAchievementsService( |
398 | 407 | } |
399 | 408 |
|
400 | 409 | var json = await response.Content.ReadAsStringAsync(); |
401 | | - return JsonSerializer.Deserialize<List<RaEarnedAchievement>>(json); |
| 410 | + return DeserializeApiResponse<List<RaEarnedAchievement>>(json, nameof(GetAchievementsEarnedBetweenAsync)); |
402 | 411 | } |
403 | 412 | catch (RaUnauthorizedException) |
404 | 413 | { |
@@ -433,7 +442,8 @@ public RetroAchievementsService( |
433 | 442 | } |
434 | 443 |
|
435 | 444 | var json = await response.Content.ReadAsStringAsync(); |
436 | | - var apiResponse = JsonSerializer.Deserialize<RaUserCompletionProgressResponse>(json); |
| 445 | + var apiResponse = DeserializeApiResponse<RaUserCompletionProgressResponse>(json, |
| 446 | + nameof(GetUserCompletionProgressAsync)); |
437 | 447 |
|
438 | 448 | if (apiResponse?.Results == null) return null; |
439 | 449 |
|
@@ -458,4 +468,58 @@ public RetroAchievementsService( |
458 | 468 | return null; |
459 | 469 | } |
460 | 470 | } |
461 | | -} |
| 471 | + |
| 472 | + /// <summary> |
| 473 | + /// Deserializes a RetroAchievements API payload. An HTML or empty payload is an expected |
| 474 | + /// external condition (Cloudflare challenge, rate limit, maintenance page) and is logged at |
| 475 | + /// Information so it never reaches the bug report service (bugs #68823-68828). |
| 476 | + /// </summary> |
| 477 | + private T? DeserializeApiResponse<T>(string json, string operation) |
| 478 | + { |
| 479 | + if (!TryGetJsonPayload(json, operation, out var payload)) return default; |
| 480 | + |
| 481 | + try |
| 482 | + { |
| 483 | + return JsonSerializer.Deserialize<T>(payload); |
| 484 | + } |
| 485 | + catch (JsonException ex) |
| 486 | + { |
| 487 | + // The API returned a payload that is not valid JSON: not an application bug. |
| 488 | + _logger.Information(ex, |
| 489 | + $"[RA Service] Invalid JSON response in {operation}; the API may be temporarily unavailable."); |
| 490 | + return default; |
| 491 | + } |
| 492 | + } |
| 493 | + |
| 494 | + /// <summary> |
| 495 | + /// Returns false for empty or non-JSON (HTML) API payloads, logging them at Information |
| 496 | + /// level so they stay out of the bug report service. |
| 497 | + /// </summary> |
| 498 | + private bool TryGetJsonPayload(string json, string operation, out string payload) |
| 499 | + { |
| 500 | + payload = json; |
| 501 | + |
| 502 | + if (string.IsNullOrWhiteSpace(json)) |
| 503 | + { |
| 504 | + _logger.Information( |
| 505 | + $"[RA Service] Empty response in {operation}; the RetroAchievements API may be temporarily unavailable."); |
| 506 | + return false; |
| 507 | + } |
| 508 | + |
| 509 | + var trimmed = json.AsSpan().TrimStart(); |
| 510 | + if (!trimmed.IsEmpty && trimmed[0] == '<') |
| 511 | + { |
| 512 | + _logger.Information( |
| 513 | + $"[RA Service] Non-JSON response in {operation}; the RetroAchievements API returned an HTML page (maintenance, rate limit or challenge). Response starts with: {GetResponseSnippet(json)}"); |
| 514 | + return false; |
| 515 | + } |
| 516 | + |
| 517 | + return true; |
| 518 | + } |
| 519 | + |
| 520 | + private static string GetResponseSnippet(string response) |
| 521 | + { |
| 522 | + var singleLine = response.ReplaceLineEndings(" ").Trim(); |
| 523 | + return singleLine.Length <= 120 ? singleLine : $"{singleLine[..120]}..."; |
| 524 | + } |
| 525 | +} |