Skip to content

Commit 7bd78ac

Browse files
authored
Skip multipart parts with empty Content-Type instead of raising (#598)
1 parent 0eb57fe commit 7bd78ac

2 files changed

Lines changed: 85 additions & 50 deletions

File tree

‎gql/transport/aiohttp.py‎

Lines changed: 53 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -540,70 +540,73 @@ async def _parse_multipart_part(
540540
:param part: aiohttp BodyPartReader for the part
541541
:return: ExecutionResult or None if part is empty/heartbeat
542542
"""
543-
# Verify the part has the correct content type
543+
try:
544+
body = await part.text()
545+
body = body.strip()
546+
except UnicodeDecodeError as e:
547+
log.warning(f"Failed to decode part: {ascii(e)}")
548+
return None
549+
544550
content_type = part.headers.get(aiohttp.hdrs.CONTENT_TYPE, "")
551+
552+
if not content_type and not body:
553+
log.debug("Skipping part with no content-type and no body")
554+
return None
555+
556+
# Verify the part has the correct content type
545557
if not content_type.startswith("application/json"):
546558
raise TransportProtocolError(
547559
f"Unexpected part content-type: {content_type}. "
548560
"Expected 'application/json'."
549561
)
550562

551-
try:
552-
# Read the part content as text
553-
body = await part.text()
554-
body = body.strip()
555-
556-
if log.isEnabledFor(logging.DEBUG):
557-
log.debug("<<< %s", ascii(body or "(empty body, skipping)"))
563+
if log.isEnabledFor(logging.DEBUG):
564+
log.debug("<<< %s", ascii(body or "(empty body, skipping)"))
558565

559-
if not body:
560-
return None
566+
if not body:
567+
return None
561568

562-
# Parse JSON body using custom deserializer
569+
try:
563570
data = self.json_deserialize(body)
564-
565-
# Handle heartbeats - empty JSON objects
566-
if not data:
567-
log.debug("Received heartbeat, ignoring")
568-
return None
569-
570-
# The multipart subscription protocol wraps data in a "payload" property
571-
if "payload" not in data:
572-
log.warning("Invalid response: missing 'payload' field")
573-
return None
574-
575-
payload = data["payload"]
576-
577-
# Check for transport-level errors (payload is null)
578-
if payload is None:
579-
# If there are errors, this is a transport-level error
580-
errors = data.get("errors")
581-
if errors:
582-
error_messages = [
583-
error.get("message", "Unknown transport error")
584-
for error in errors
585-
]
586-
587-
for message in error_messages:
588-
log.error(f"Transport error: {message}")
589-
590-
raise TransportServerError("\n\n".join(error_messages))
591-
else:
592-
# Null payload without errors - just skip this part
593-
return None
594-
595-
# Extract GraphQL data from payload
596-
return ExecutionResult(
597-
data=payload.get("data"),
598-
errors=payload.get("errors"),
599-
extensions=payload.get("extensions"),
600-
)
601571
except json.JSONDecodeError as e:
602572
log.warning(
603573
f"Failed to parse JSON: {ascii(e)}, "
604574
f"body: {ascii(body[:100]) if body else ''}"
605575
)
606576
return None
607-
except UnicodeDecodeError as e:
608-
log.warning(f"Failed to decode part: {ascii(e)}")
577+
578+
# Handle heartbeats - empty JSON objects
579+
if not data:
580+
log.debug("Received heartbeat, ignoring")
581+
return None
582+
583+
# The multipart subscription protocol wraps data in a "payload" property
584+
if "payload" not in data:
585+
log.warning("Invalid response: missing 'payload' field")
609586
return None
587+
588+
payload = data["payload"]
589+
590+
# Check for transport-level errors (payload is null)
591+
if payload is None:
592+
# If there are errors, this is a transport-level error
593+
errors = data.get("errors")
594+
if errors:
595+
error_messages = [
596+
error.get("message", "Unknown transport error") for error in errors
597+
]
598+
599+
for message in error_messages:
600+
log.error(f"Transport error: {message}")
601+
602+
raise TransportServerError("\n\n".join(error_messages))
603+
else:
604+
# Null payload without errors - just skip this part
605+
return None
606+
607+
# Extract GraphQL data from payload
608+
return ExecutionResult(
609+
data=payload.get("data"),
610+
errors=payload.get("errors"),
611+
extensions=payload.get("extensions"),
612+
)

‎tests/test_aiohttp_multipart.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,38 @@ async def test_aiohttp_multipart_wrong_part_content_type(multipart_server):
480480
assert "text/html" in str(exc_info.value)
481481

482482

483+
@pytest.mark.asyncio
484+
async def test_aiohttp_multipart_empty_part_no_content_type_skipped(multipart_server):
485+
"""Test that empty parts with no content-type are skipped."""
486+
from gql.transport.aiohttp import AIOHTTPTransport
487+
488+
book1_payload = json.dumps({"payload": {"data": {"book": book1}}})
489+
490+
parts = [
491+
("--graphql\r\n" "\r\n" "\r\n"),
492+
(
493+
"--graphql\r\n"
494+
"Content-Type: application/json\r\n"
495+
"\r\n"
496+
f"{book1_payload}\r\n"
497+
),
498+
"--graphql--\r\n",
499+
]
500+
501+
server = await multipart_server(parts)
502+
url = server.make_url("/")
503+
transport = AIOHTTPTransport(url=url)
504+
505+
async with Client(transport=transport) as session:
506+
query = gql(subscription_str)
507+
results = []
508+
async for result in session.subscribe(query):
509+
results.append(result)
510+
511+
assert len(results) == 1
512+
assert results[0]["book"]["title"] == "Book 1"
513+
514+
483515
@pytest.mark.asyncio
484516
async def test_aiohttp_multipart_response_headers(multipart_server):
485517
"""Test that response headers are captured in the transport."""

0 commit comments

Comments
 (0)