MS-Graph returns 404 or 412 or Timeouts when uploading large attachment chunks

Nikolaos Dimizas 0 Reputation points
2025-09-29T11:05:42.7833333+00:00

I have the following python script which is sending emails through MS-Graph using msgraph-sdk in python.
I use the ms-graph-sdk to create the draft email and the upload sessions for the attachments.

The problem I experience is with chunk uploads for large attachments. The python ms-graph sdk does not natively support uploading the attachment chunks so I am using an http client (aiohttp) to upload the chunks using the uploadUrl I receive from the upload session response.

When my service is running on my Azure env I get Timeout errors quite frequently when uploading attachment chunks. My upload requests are sequential. I have a retry mechanism in place which 99% of the times manages to send the email eventually but there are occassions where the email sending is failing after 3 retry attempts.
I have also seen more rarely some 404 or 400 responses when trying to upload an attachment chunk which doesn't make sense since the uploadUrl is provided by MS-Graph itself.

Here is my python code

import aiohttp

from mylib import EmailSender
from mylib import (
    EmailDefinition,
    EmailContent,
    EncodedAttachment,
)
from azure.identity import ClientSecretCredential
from msgraph.graph_service_client import GraphServiceClient
from mylib import get_logger
from msgraph.generated.models.message import Message
from msgraph.generated.models.item_body import ItemBody
from msgraph.generated.models.body_type import BodyType
from msgraph.generated.models.recipient import Recipient
from msgraph.generated.models.email_address import EmailAddress
from msgraph.generated.models.attachment_type import AttachmentType
from msgraph.generated.models.attachment_item import AttachmentItem
from msgraph.generated.users.item.messages.item.attachments.create_upload_session.create_upload_session_post_request_body import (
    CreateUploadSessionPostRequestBody,
)

logger = get_logger(__name__)

CHUNK_SIZE = 3 * 1024 * 1024  # 3MB
CHUNK_UPLOAD_TIMEOUT_SECONDS = 20


class EmailSenderMSG(EmailSender):
    def __init__(self, cfg):
        _credential = ClientSecretCredential(
            tenant_id=cfg.tenant_id,
            client_id=cfg.client_id,
            client_secret=cfg.client_secret.get_secret_value(),
        )
        self._graph_client = GraphServiceClient(
            credentials=_credential, scopes=[cfg.scope]
        )

    async def _send_large_email(self, email_def: EmailDefinition):
        # Implementation reference https://learn.microsoft.com/en-us/graph/outlook-large-attachments?tabs=python
        # Step 1: Create Draft
        message = self.create_base_message(email_def)

        draft = self._graph_client.users.by_user_id(email_def.sender_address).messages.post(message)

        # Step 2: Upload attachments sequentially
        for att in email_def.attachments:
            await self.upload_attachment(email_def.sender_address, draft.id, att)
            
        # Step 4: Send the draft
        logger.info(f"Finalising draft message with id {draft.id}")
        await (self._graph_client.users.by_user_id(email_def.sender_address)
               .messages.by_message_id(draft.id).send.post())

    async def upload_attachment(
            self, sender_address: str, draft_message_id: str, attachment: EncodedAttachment) -> None:
        attachment_size = len(attachment.content)
        upload_session_body = CreateUploadSessionPostRequestBody(
            attachment_item=AttachmentItem(
                attachment_type=AttachmentType.File,
                name=attachment.name,
                size=attachment_size,
                content_type=attachment.content_type,
            )
        )

        logger.info(
            f"Creating upload session for attachment '{attachment.name}' of size {attachment_size} bytes. Request body: {upload_session_body}"
        )
        upload_session = await (
            self._graph_client.users.by_user_id(sender_address).messages.by_message_id(draft_message_id)
            .attachments.create_upload_session.post(upload_session_body))
        logger.debug(f"Upload session for attachment '{attachment.name}' : {upload_session}")

        # Step 3: Upload in chunks (simple, no retries for now)
        await self.upload_chunks(attachment, attachment_size, upload_session)

    @staticmethod
    async def upload_chunks(attachment, attachment_size, upload_session):
        async with aiohttp.ClientSession() as session:
            for i in range(0, attachment_size, CHUNK_SIZE):
                chunk = attachment.content[i: i + CHUNK_SIZE]
                start = i
                end = i + len(chunk) - 1
                total = attachment_size

                # msgraph-dsk is not supporting attachment chunk uploading, so we use aiohttp directly
                headers = {
                    "Content-Length": str(len(chunk)),
                    "Content-Range": f"bytes {start}-{end}/{total}",
                    "Content-Type": "application/octet-stream",
                }

                logger.debug(
                    f"Uploading chunk of attachment '{attachment.name}' with url {upload_session.upload_url} and headers {headers}"
                )
                await EmailSenderMSG.upload_single_chunk(
                    session,
                    upload_session.upload_url,
                    chunk,
                    headers,
                    attachment.name,
                )

    @staticmethod
    async def upload_single_chunk(
            session: aiohttp.ClientSession, url: str, chunk: bytes, headers: dict[str, str], attachment_name: str
    ) -> None:
        async with session.put(
                url,
                data=chunk,
                headers=headers,
                timeout=aiohttp.ClientTimeout(total=CHUNK_UPLOAD_TIMEOUT_SECONDS),
        ) as response:
            try:
                response.raise_for_status()
            except aiohttp.ClientResponseError as e:
                logger.warning(
                    f"ClientResponseError: {e.status}, attachment '{attachment_name}', {e.message}, Headers: {e.headers}",
                    exc_info=True,
                )
                raise

    @staticmethod
    def _create_item_body(email_content: EmailContent) -> ItemBody:
        if email_content.html:
            return ItemBody(content_type=BodyType.Html, content=email_content.html)
        elif email_content.plain_text:
            return ItemBody(
                content_type=BodyType.Text, content=email_content.plain_text
            )

        raise ValueError("Email content must have either 'html' or 'plain_text' set.")

    @staticmethod
    def create_base_message(email_def):
        message = Message(
            subject=email_def.content.subject,
            body=EmailSenderMSG._create_item_body(email_def.content),
            to_recipients=[
                Recipient(email_address=EmailAddress(address=addr))
                for addr in email_def.recipients.to
            ],
            cc_recipients=[
                Recipient(email_address=EmailAddress(address=addr))
                for addr in email_def.recipients.cc
            ],
            bcc_recipients=[
                Recipient(email_address=EmailAddress(address=addr))
                for addr in email_def.recipients.bcc
            ],
        )
        return message

thanks

Microsoft Security | Microsoft Graph

Your answer

Answers can be marked as 'Accepted' by the question author and 'Recommended' by moderators, which helps users know the answer solved the author's problem.