Skip to content

Instantly share code, notes, and snippets.

@odysseus0
Last active June 20, 2025 00:32
Show Gist options
  • Save odysseus0/e787698e158cfe400f4dd82d272bad35 to your computer and use it in GitHub Desktop.
Save odysseus0/e787698e158cfe400f4dd82d272bad35 to your computer and use it in GitHub Desktop.
HTTP Client Detective - Systematically test Python HTTP clients to find which ones work with your API
#!/usr/bin/env python3
# /// script
# dependencies = [
# "httpx",
# "aiohttp",
# "requests",
# ]
# requires-python = ">=3.7"
# ///
"""
HTTP Client Detective - Systematically test Python HTTP clients
https://gist.github.com/odysseus0/e787698e158cfe400f4dd82d272bad35
This script helped me discover that httpx AsyncClient was the only
Python HTTP client that failed with TikAPI, leading to a 3-hour
debugging adventure that could have been avoided by just using aiohttp.
Usage: uv run http_client_detective.py
"""
import asyncio
import json
import time
import urllib.request
from typing import Dict, Any, List
import aiohttp
import httpx
import requests
async def test_single_client(name: str, test_func, url: str, headers: Dict[str, str]) -> Dict[str, Any]:
"""Test a single HTTP client."""
print(f"\n{name}")
try:
start = time.time()
await test_func(url, headers)
duration = time.time() - start
return {
'status': 'βœ… SUCCESS',
'duration': f'{duration:.2f}s',
'response': 'success'
}
except Exception as e:
result = {
'status': '❌ FAILED',
'error': f"{type(e).__name__}: {e}"
}
# Special handling for httpx AsyncClient to show empty error
if 'httpx' in name.lower() and 'async' in name.lower():
result['error_repr'] = repr(str(e))
return result
async def test_urllib(url: str, headers: Dict[str, str]) -> None:
"""Test urllib (standard library)."""
req = urllib.request.Request(url, headers=headers)
response = urllib.request.urlopen(req, timeout=10)
json.loads(response.read().decode())
async def test_requests(url: str, headers: Dict[str, str]) -> None:
"""Test requests library."""
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
response.json()
async def test_httpx_sync(url: str, headers: Dict[str, str]) -> None:
"""Test httpx synchronous client."""
with httpx.Client(timeout=10) as client:
response = client.get(url, headers=headers)
response.raise_for_status()
response.json()
async def test_httpx_async(url: str, headers: Dict[str, str]) -> None:
"""Test httpx asynchronous client."""
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
response.json()
async def test_aiohttp(url: str, headers: Dict[str, str]) -> None:
"""Test aiohttp library."""
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as response:
response.raise_for_status()
await response.json()
async def test_all_clients(url: str, headers: Dict[str, str]) -> Dict[str, Any]:
"""Test all HTTP clients against the given URL."""
print(f"πŸ” Testing HTTP clients against: {url}")
print("=" * 60)
clients = [
("Testing urllib (standard library)...", test_urllib),
("Testing requests...", test_requests),
("Testing httpx (sync)...", test_httpx_sync),
("Testing httpx (async)...", test_httpx_async),
("Testing aiohttp...", test_aiohttp),
]
results = {}
client_names = ['urllib', 'requests', 'httpx_sync', 'httpx_async', 'aiohttp']
for (name, test_func), client_name in zip(clients, client_names):
results[client_name] = await test_single_client(name, test_func, url, headers)
return results
def print_results(results: Dict[str, Any]):
"""Pretty print the test results."""
print("\n" + "=" * 60)
print("πŸ“Š TEST RESULTS")
print("=" * 60)
# Summary table
print(f"\n{'Client':<15} {'Status':<12} {'Duration':<10} {'Details'}")
print("-" * 60)
for client, result in results.items():
status = result['status']
duration = result.get('duration', 'N/A')
details = result.get('error', result.get('response', ''))
if 'error_repr' in result:
details += f" (repr: {result['error_repr']})"
# Truncate long error messages
if len(details) > 40:
details = details[:37] + "..."
print(f"{client:<15} {status:<12} {duration:<10} {details}")
# Analysis
print("\nπŸ“ˆ ANALYSIS:")
successes = [k for k, v in results.items() if v['status'] == 'βœ… SUCCESS']
failures = [k for k, v in results.items() if v['status'] == '❌ FAILED']
if successes:
print(f"βœ… Working clients: {', '.join(successes)}")
if failures:
print(f"❌ Failed clients: {', '.join(failures)}")
# The key insight
if 'httpx_async' in failures and 'httpx_sync' in successes:
print("\nπŸ€” INTERESTING: httpx sync works but async fails!")
print(" This suggests an async-specific issue (spoiler: IPv6)")
if 'aiohttp' in successes and 'httpx_async' in failures:
print("\nπŸ’‘ SOLUTION: Just use aiohttp instead of httpx.AsyncClient!")
async def main():
"""Run the HTTP client detective.
This tests against api.tikapi.io without an API key to demonstrate that:
- Most HTTP clients get a 400 error (they successfully connect)
- httpx AsyncClient fails with empty ConnectError (IPv6 TLS handshake fails)
- aiohttp just works!
"""
# Test configuration
test_cases = [
{
'name': 'TikAPI /public/check (without API key)',
'url': 'https://api.tikapi.io/public/check',
'headers': {
'Accept': 'application/json'
}
},
# Add more test cases here if needed
]
for test in test_cases:
print(f"\n🎯 Test Case: {test['name']}")
results = await test_all_clients(test['url'], test['headers'])
print_results(results)
print("\n" + "=" * 60)
print("🏁 Testing complete!")
print("\nπŸ” KEY INSIGHT:")
print("httpx AsyncClient fails with empty ConnectError (IPv6 TLS handshake)")
print("while all other clients work. The fix? Just use aiohttp! πŸŽ‰")
if __name__ == "__main__":
asyncio.run(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment