chat proxy: multipart через files-only — data+files list ломает httpx

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 13:19:01 +03:00
parent 95b0fd34d8
commit 7b8780c6ad
2 changed files with 23 additions and 10 deletions
+15 -8
View File
@@ -62,24 +62,31 @@ def _upstream_request_args(request, token: str) -> tuple[dict, HttpResponse | No
return {'headers': headers}, None
if _is_multipart(request):
data = []
# list+list data/files в httpx ломает multipart (TypeError: tuple in join).
# Текстовые поля тоже через files=(None, value).
multipart = []
total = 0
for key in request.POST:
for value in request.POST.getlist(key):
data.append((key, value))
files = []
total = 0
multipart.append((key, (None, str(value))))
for key in request.FILES:
for uploaded in request.FILES.getlist(key):
total += uploaded.size
if total > _MAX_BODY:
return {}, HttpResponse(status=413)
content = uploaded.read()
files.append((
if isinstance(content, str):
content = content.encode('utf-8')
multipart.append((
key,
(uploaded.name, content, uploaded.content_type or 'application/octet-stream'),
(
uploaded.name or 'file',
content,
uploaded.content_type or 'application/octet-stream',
),
))
# Content-Type с boundary выставит httpx сам — исходный boundary уже «съеден».
return {'headers': headers, 'data': data or None, 'files': files or None}, None
# Content-Type с boundary выставит httpx сам.
return {'headers': headers, 'files': multipart or None}, None
content_type = request.headers.get('Content-Type')
if content_type:
+8 -2
View File
@@ -5,6 +5,7 @@
from datetime import date
from unittest import mock
import httpx
from django.contrib.auth import get_user_model
from django.test import TestCase
from mattermostautodriver.exceptions import ResourceNotFound
@@ -456,8 +457,13 @@ class ChatApiProxyTests(TestCase):
self.assertEqual(_args[0], 'POST')
self.assertTrue(_args[1].endswith('/api/v4/files'))
self.assertIn('files', kwargs)
self.assertIn('data', kwargs)
self.assertNotIn('data', kwargs)
self.assertNotIn('content', kwargs)
# httpx сам ставит multipart boundary
self.assertNotIn('Content-Type', kwargs['headers'])
self.assertEqual(kwargs['headers']['Authorization'], 'Bearer user-pat-token')
# httpx реально умеет закодировать этот формат
built = httpx.Request('POST', 'https://mm.test.local/api/v4/files', files=kwargs['files'])
self.assertIn('multipart/form-data', built.headers['content-type'])
body = built.read()
self.assertIn(b'channel_id', body)
self.assertIn(b'hello', body)