Skip to content

Instantly share code, notes, and snippets.

@MaxGhenis
Created January 4, 2026 14:31
Show Gist options
  • Select an option

  • Save MaxGhenis/82df7d25e2859835d6695f945b29bb22 to your computer and use it in GitHub Desktop.

Select an option

Save MaxGhenis/82df7d25e2859835d6695f945b29bb22 to your computer and use it in GitHub Desktop.
OpenCollective GraphQL uploadFile mutation returns 500 error - reproducible example
Display the source blob
Display the rendered blob
Raw
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# GraphQL Multipart File Upload Returns 500 Error\n",
"\n",
"This notebook demonstrates that the GraphQL `uploadFile` mutation returns a 500 error when using the standard GraphQL multipart request spec, while the REST `/images` endpoint works correctly.\n",
"\n",
"## Environment\n",
"- Python 3.13\n",
"- requests library\n",
"- Valid OAuth2 token with `expenses` scope"
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {
"execution": {
"iopub.execute_input": "2026-01-04T14:31:12.763306Z",
"iopub.status.busy": "2026-01-04T14:31:12.763155Z",
"iopub.status.idle": "2026-01-04T14:31:12.793814Z",
"shell.execute_reply": "2026-01-04T14:31:12.793343Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Test PNG size: 70 bytes\n"
]
}
],
"source": [
"import requests\n",
"import json\n",
"import base64\n",
"from io import BytesIO\n",
"\n",
"# Create a minimal valid PNG (1x1 red pixel)\n",
"TEST_PNG = base64.b64decode(\n",
" 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='\n",
")\n",
"print(f\"Test PNG size: {len(TEST_PNG)} bytes\")"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {
"execution": {
"iopub.execute_input": "2026-01-04T14:31:12.810124Z",
"iopub.status.busy": "2026-01-04T14:31:12.810024Z",
"iopub.status.idle": "2026-01-04T14:31:12.812348Z",
"shell.execute_reply": "2026-01-04T14:31:12.811997Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Token loaded: eyJhbGciOiJIUzI1NiIs...\n"
]
}
],
"source": [
"# Load OAuth token (replace with your own or set ACCESS_TOKEN env var)\n",
"import os\n",
"\n",
"ACCESS_TOKEN = os.environ.get('OPENCOLLECTIVE_ACCESS_TOKEN')\n",
"if not ACCESS_TOKEN:\n",
" token_file = os.path.expanduser('~/.config/opencollective/token.json')\n",
" if os.path.exists(token_file):\n",
" with open(token_file) as f:\n",
" ACCESS_TOKEN = json.load(f)['access_token']\n",
"\n",
"print(f\"Token loaded: {ACCESS_TOKEN[:20] if ACCESS_TOKEN else 'NOT FOUND'}...\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Verify Authentication Works\n",
"\n",
"First, let's confirm the token is valid by querying the logged-in user:"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {
"execution": {
"iopub.execute_input": "2026-01-04T14:31:12.813415Z",
"iopub.status.busy": "2026-01-04T14:31:12.813308Z",
"iopub.status.idle": "2026-01-04T14:31:12.959388Z",
"shell.execute_reply": "2026-01-04T14:31:12.958597Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Status: 200\n",
"Response: {\n",
" \"data\": {\n",
" \"me\": {\n",
" \"id\": \"x8k03rey-d5agmq5z-r8yqlbwo-z7j4nxv9\",\n",
" \"slug\": \"max-ghenis\",\n",
" \"name\": \"Max Ghenis\"\n",
" }\n",
" }\n",
"}\n"
]
}
],
"source": [
"API_URL = 'https://api.opencollective.com/graphql/v2'\n",
"\n",
"# Simple query to verify auth\n",
"query = '{ me { id slug name } }'\n",
"\n",
"response = requests.post(\n",
" API_URL,\n",
" json={'query': query},\n",
" headers={\n",
" 'Content-Type': 'application/json',\n",
" 'Authorization': f'Bearer {ACCESS_TOKEN}'\n",
" }\n",
")\n",
"\n",
"print(f\"Status: {response.status_code}\")\n",
"print(f\"Response: {json.dumps(response.json(), indent=2)}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Verify Upload Scalar Exists\n",
"\n",
"Check that the `Upload` scalar is defined in the schema:"
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {
"execution": {
"iopub.execute_input": "2026-01-04T14:31:12.961546Z",
"iopub.status.busy": "2026-01-04T14:31:12.961367Z",
"iopub.status.idle": "2026-01-04T14:31:13.119905Z",
"shell.execute_reply": "2026-01-04T14:31:13.119420Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Upload scalar exists: {'data': {'__type': {'name': 'Upload', 'kind': 'SCALAR', 'description': 'The `Upload` scalar type represents a file upload.'}}}\n"
]
}
],
"source": [
"query = '''{\n",
" __type(name: \"Upload\") {\n",
" name\n",
" kind\n",
" description\n",
" }\n",
"}'''\n",
"\n",
"response = requests.post(\n",
" API_URL,\n",
" json={'query': query},\n",
" headers={\n",
" 'Content-Type': 'application/json',\n",
" 'Authorization': f'Bearer {ACCESS_TOKEN}'\n",
" }\n",
")\n",
"\n",
"print(f\"Upload scalar exists: {response.json()}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## BUG: GraphQL Multipart Upload Returns 500\n",
"\n",
"Following the [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec), this should work but returns 500:"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {
"execution": {
"iopub.execute_input": "2026-01-04T14:31:13.121105Z",
"iopub.status.busy": "2026-01-04T14:31:13.121015Z",
"iopub.status.idle": "2026-01-04T14:31:13.253294Z",
"shell.execute_reply": "2026-01-04T14:31:13.252907Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"GraphQL multipart upload status: 500\n",
"Response: {\"error\":{\"code\":500}}\n"
]
}
],
"source": [
"mutation = '''mutation UploadFile($files: [UploadFileInput!]!) {\n",
" uploadFile(files: $files) {\n",
" file {\n",
" id\n",
" url\n",
" name\n",
" }\n",
" }\n",
"}'''\n",
"\n",
"operations = {\n",
" 'query': mutation,\n",
" 'variables': {\n",
" 'files': [{'kind': 'EXPENSE_ATTACHED_FILE', 'file': None}]\n",
" }\n",
"}\n",
"\n",
"file_map = {'0': ['variables.files.0.file']}\n",
"\n",
"files = {\n",
" 'operations': (None, json.dumps(operations), 'application/json'),\n",
" 'map': (None, json.dumps(file_map), 'application/json'),\n",
" '0': ('test.png', BytesIO(TEST_PNG), 'image/png'),\n",
"}\n",
"\n",
"response = requests.post(\n",
" API_URL,\n",
" files=files,\n",
" headers={'Authorization': f'Bearer {ACCESS_TOKEN}'}\n",
")\n",
"\n",
"print(f\"GraphQL multipart upload status: {response.status_code}\")\n",
"print(f\"Response: {response.text}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Same Request with Apollo-Require-Preflight Header\n",
"\n",
"The frontend uses `Apollo-Require-Preflight: true` header. Let's try that:"
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {
"execution": {
"iopub.execute_input": "2026-01-04T14:31:13.254341Z",
"iopub.status.busy": "2026-01-04T14:31:13.254270Z",
"iopub.status.idle": "2026-01-04T14:31:13.412701Z",
"shell.execute_reply": "2026-01-04T14:31:13.412294Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"With Apollo-Require-Preflight header status: 500\n",
"Response: {\"error\":{\"code\":500}}\n"
]
}
],
"source": [
"files = {\n",
" 'operations': (None, json.dumps(operations), 'application/json'),\n",
" 'map': (None, json.dumps(file_map), 'application/json'),\n",
" '0': ('test.png', BytesIO(TEST_PNG), 'image/png'),\n",
"}\n",
"\n",
"response = requests.post(\n",
" API_URL,\n",
" files=files,\n",
" headers={\n",
" 'Authorization': f'Bearer {ACCESS_TOKEN}',\n",
" 'Apollo-Require-Preflight': 'true'\n",
" }\n",
")\n",
"\n",
"print(f\"With Apollo-Require-Preflight header status: {response.status_code}\")\n",
"print(f\"Response: {response.text}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## WORKAROUND: REST /images Endpoint Works\n",
"\n",
"The REST endpoint works correctly:"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {
"execution": {
"iopub.execute_input": "2026-01-04T14:31:13.413973Z",
"iopub.status.busy": "2026-01-04T14:31:13.413876Z",
"iopub.status.idle": "2026-01-04T14:31:13.832887Z",
"shell.execute_reply": "2026-01-04T14:31:13.832318Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"REST /images endpoint status: 200\n",
"Response: {\n",
" \"status\": 200,\n",
" \"url\": \"https://opencollective.com/api/files/a47byg9n-xozdp8y5-gr9qmjlv-03rek5w8\"\n",
"}\n"
]
}
],
"source": [
"REST_URL = 'https://api.opencollective.com/images'\n",
"\n",
"files = {\n",
" 'file': ('test.png', BytesIO(TEST_PNG), 'image/png'),\n",
"}\n",
"\n",
"data = {\n",
" 'kind': 'EXPENSE_ATTACHED_FILE',\n",
"}\n",
"\n",
"response = requests.post(\n",
" REST_URL,\n",
" files=files,\n",
" data=data,\n",
" headers={'Authorization': f'Bearer {ACCESS_TOKEN}'}\n",
")\n",
"\n",
"print(f\"REST /images endpoint status: {response.status_code}\")\n",
"print(f\"Response: {json.dumps(response.json(), indent=2)}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Summary\n",
"\n",
"| Endpoint | Method | Status |\n",
"|----------|--------|--------|\n",
"| `/graphql/v2` | GraphQL multipart | **500 Error** |\n",
"| `/graphql/v2` | GraphQL multipart + Apollo header | **500 Error** |\n",
"| `/images` | REST multipart | **200 OK** |\n",
"\n",
"The GraphQL `uploadFile` mutation is defined in the schema and the `Upload` scalar exists, but multipart uploads via the standard spec return 500 errors. The REST `/images` endpoint works as a workaround."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.11"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment