Created
January 4, 2026 14:31
-
-
Save MaxGhenis/82df7d25e2859835d6695f945b29bb22 to your computer and use it in GitHub Desktop.
OpenCollective GraphQL uploadFile mutation returns 500 error - reproducible example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "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