Add error-only response detection for GraphQL responses
This commit is contained in:
parent
c9b74634c5
commit
d63ede1a47
2 changed files with 229 additions and 0 deletions
|
|
@ -221,6 +221,51 @@ def detect_non_campaign_keyword(raw_text: str) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_error_only_response(
|
||||||
|
parsed_json: JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Detect if response contains only GraphQL errors and no data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parsed_json: The parsed JSON data from the file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Error description if only errors present, None if data exists.
|
||||||
|
"""
|
||||||
|
# Handle tuple from json_repair
|
||||||
|
if isinstance(parsed_json, tuple):
|
||||||
|
parsed_json = parsed_json[0]
|
||||||
|
|
||||||
|
# Check if it's a list of responses
|
||||||
|
if isinstance(parsed_json, list):
|
||||||
|
for item in parsed_json:
|
||||||
|
if isinstance(item, dict) and "errors" in item:
|
||||||
|
errors: Any = item.get("errors")
|
||||||
|
data: Any = item.get("data")
|
||||||
|
# Data is missing if key doesn't exist or value is None
|
||||||
|
if errors and data is None and isinstance(errors, list) and len(errors) > 0:
|
||||||
|
first_error: dict[str, Any] = errors[0]
|
||||||
|
message: str = first_error.get("message", "unknown error")
|
||||||
|
return f"error_only: {message}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(parsed_json, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if it's a single response dict
|
||||||
|
if "errors" in parsed_json:
|
||||||
|
errors = parsed_json.get("errors")
|
||||||
|
data = parsed_json.get("data")
|
||||||
|
|
||||||
|
# Data is missing if key doesn't exist or value is None
|
||||||
|
if errors and data is None and isinstance(errors, list) and len(errors) > 0:
|
||||||
|
first_error = errors[0]
|
||||||
|
message = first_error.get("message", "unknown error")
|
||||||
|
return f"error_only: {message}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def extract_operation_name_from_parsed(
|
def extract_operation_name_from_parsed(
|
||||||
payload: JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str,
|
payload: JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
|
|
@ -1197,6 +1242,18 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
operation_name: str | None = extract_operation_name_from_parsed(parsed_json)
|
operation_name: str | None = extract_operation_name_from_parsed(parsed_json)
|
||||||
|
|
||||||
|
# Check for error-only responses first
|
||||||
|
error_description: str | None = detect_error_only_response(parsed_json)
|
||||||
|
if error_description:
|
||||||
|
if not options.get("skip_broken_moves"):
|
||||||
|
broken_dir: Path | None = move_file_to_broken_subdir(
|
||||||
|
file_path,
|
||||||
|
error_description,
|
||||||
|
operation_name=operation_name,
|
||||||
|
)
|
||||||
|
return {"success": False, "broken_dir": str(broken_dir), "reason": error_description}
|
||||||
|
return {"success": False, "broken_dir": "(skipped)", "reason": error_description}
|
||||||
|
|
||||||
matched: str | None = detect_non_campaign_keyword(raw_text)
|
matched: str | None = detect_non_campaign_keyword(raw_text)
|
||||||
if matched:
|
if matched:
|
||||||
if not options.get("skip_broken_moves"):
|
if not options.get("skip_broken_moves"):
|
||||||
|
|
@ -1287,6 +1344,26 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
operation_name: str | None = extract_operation_name_from_parsed(parsed_json)
|
operation_name: str | None = extract_operation_name_from_parsed(parsed_json)
|
||||||
|
|
||||||
|
# Check for error-only responses first
|
||||||
|
error_description: str | None = detect_error_only_response(parsed_json)
|
||||||
|
if error_description:
|
||||||
|
if not options.get("skip_broken_moves"):
|
||||||
|
broken_dir: Path | None = move_file_to_broken_subdir(
|
||||||
|
file_path,
|
||||||
|
error_description,
|
||||||
|
operation_name=operation_name,
|
||||||
|
)
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → "
|
||||||
|
f"{broken_dir}/{file_path.name} "
|
||||||
|
f"({error_description})",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
progress_bar.write(
|
||||||
|
f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} ({error_description}, move skipped)",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
matched: str | None = detect_non_campaign_keyword(raw_text)
|
matched: str | None = detect_non_campaign_keyword(raw_text)
|
||||||
if matched:
|
if matched:
|
||||||
if not options.get("skip_broken_moves"):
|
if not options.get("skip_broken_moves"):
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from twitch.management.commands.better_import_drops import Command
|
from twitch.management.commands.better_import_drops import Command
|
||||||
|
from twitch.management.commands.better_import_drops import detect_error_only_response
|
||||||
from twitch.models import Channel
|
from twitch.models import Channel
|
||||||
from twitch.models import DropBenefit
|
from twitch.models import DropBenefit
|
||||||
from twitch.models import DropCampaign
|
from twitch.models import DropCampaign
|
||||||
|
|
@ -681,3 +682,154 @@ class ImporterRobustnessTests(TestCase):
|
||||||
|
|
||||||
campaign = DropCampaign.objects.get(twitch_id="campaign-null-image")
|
campaign = DropCampaign.objects.get(twitch_id="campaign-null-image")
|
||||||
assert not campaign.image_url
|
assert not campaign.image_url
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorOnlyResponseDetectionTests(TestCase):
|
||||||
|
"""Tests for detecting responses that only contain GraphQL errors without data."""
|
||||||
|
|
||||||
|
def test_detects_error_only_response_with_service_timeout(self) -> None:
|
||||||
|
"""Ensure error-only response with service timeout is detected."""
|
||||||
|
parsed_json = {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "service timeout",
|
||||||
|
"path": ["currentUser", "dropCampaigns"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = detect_error_only_response(parsed_json)
|
||||||
|
assert result == "error_only: service timeout"
|
||||||
|
|
||||||
|
def test_detects_error_only_response_with_null_data(self) -> None:
|
||||||
|
"""Ensure error-only response with null data field is detected."""
|
||||||
|
parsed_json = {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "internal server error",
|
||||||
|
"path": ["data"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"data": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = detect_error_only_response(parsed_json)
|
||||||
|
assert result == "error_only: internal server error"
|
||||||
|
|
||||||
|
def test_detects_error_only_response_with_empty_data(self) -> None:
|
||||||
|
"""Ensure error-only response with empty data dict is allowed through."""
|
||||||
|
parsed_json = {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "unauthorized",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"data": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = detect_error_only_response(parsed_json)
|
||||||
|
# Empty dict {} is considered "data exists" so this should pass
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_detects_error_only_response_without_data_key(self) -> None:
|
||||||
|
"""Ensure error-only response without data key is detected."""
|
||||||
|
parsed_json = {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "missing data",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = detect_error_only_response(parsed_json)
|
||||||
|
assert result == "error_only: missing data"
|
||||||
|
|
||||||
|
def test_allows_response_with_both_errors_and_data(self) -> None:
|
||||||
|
"""Ensure responses with both errors and valid data are not flagged."""
|
||||||
|
parsed_json = {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "partial failure",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"data": {
|
||||||
|
"currentUser": {
|
||||||
|
"dropCampaigns": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = detect_error_only_response(parsed_json)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_allows_response_with_no_errors(self) -> None:
|
||||||
|
"""Ensure normal responses without errors are not flagged."""
|
||||||
|
parsed_json = {
|
||||||
|
"data": {
|
||||||
|
"currentUser": {
|
||||||
|
"dropCampaigns": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = detect_error_only_response(parsed_json)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_detects_error_only_in_list_of_responses(self) -> None:
|
||||||
|
"""Ensure error-only detection works with list of responses."""
|
||||||
|
parsed_json = [
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "rate limit exceeded",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = detect_error_only_response(parsed_json)
|
||||||
|
assert result == "error_only: rate limit exceeded"
|
||||||
|
|
||||||
|
def test_handles_json_repair_tuple_format(self) -> None:
|
||||||
|
"""Ensure error-only detection works with json_repair tuple format."""
|
||||||
|
parsed_json = (
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "service timeout",
|
||||||
|
"path": ["currentUser", "dropCampaigns"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[{"json_repair": "log"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = detect_error_only_response(parsed_json)
|
||||||
|
assert result == "error_only: service timeout"
|
||||||
|
|
||||||
|
def test_returns_none_for_non_dict_input(self) -> None:
|
||||||
|
"""Ensure non-dict input is handled gracefully."""
|
||||||
|
result = detect_error_only_response("invalid")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_returns_none_for_empty_errors_list(self) -> None:
|
||||||
|
"""Ensure empty errors list is not flagged as error-only."""
|
||||||
|
parsed_json = {
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = detect_error_only_response(parsed_json)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_handles_error_without_message_field(self) -> None:
|
||||||
|
"""Ensure errors without message field use default text."""
|
||||||
|
parsed_json = {
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"path": ["data"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = detect_error_only_response(parsed_json)
|
||||||
|
assert result == "error_only: unknown error"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue