Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 4 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,25 +231,6 @@ except httpmorph.RequestException as e:
print(f"Request failed: {e}")
```

## Performance

httpmorph is built for speed with a native C implementation and BoringSSL for optimized TLS operations.

**Benchmark vs requests library** (30-second test, macOS Apple Silicon):
- **Throughput:** 49.4% faster on average (1,056 vs 707 req/s)
- **GET requests:** 1,032 req/s vs 709 req/s (1.46x faster)
- **POST requests:** 1,080 req/s vs 705 req/s (1.53x faster)
- **Latency:** 33.1% lower on average (0.95ms vs 1.41ms)

See [detailed benchmark results](benchmarks/results_requests_macos.md) for full metrics including performance charts.

### Performance Features
- Native C implementation with minimal Python overhead
- BoringSSL for optimized TLS operations
- Connection pooling reduces handshake overhead
- HTTP/2 multiplexing for concurrent requests
- Efficient memory management

## Platform Support

| Platform | Status |
Expand Down Expand Up @@ -383,8 +364,8 @@ pytest tests/ -v

- Built on BoringSSL (Google)
- HTTP/2 support via nghttp2
- Inspired by Python's requests library
- Browser fingerprints based on real browser implementations
- Inspired by Python's requests and httpx libraries
- Browser fingerprints based on real browser implementations ( still in progress )

## FAQ

Expand All @@ -403,7 +384,8 @@ A: Please open an issue on GitHub with a minimal reproduction example and your e
## Support

- GitHub Issues: [Report bugs and feature requests](https://github.com/arman-bd/httpmorph/issues)
- Documentation: [Full API documentation](https://httpmorph.readthedocs.io) (coming soon)
- Documentation: [Full API documentation](https://httpmorph.readthedocs.io)
- PyPI: [httpmorph on PyPI](https://pypi.org/project/httpmorph/)

---

Expand Down
6 changes: 3 additions & 3 deletions benchmarks/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -1803,8 +1803,8 @@ def main():
"-s",
"--sequential",
type=int,
default=50,
help="Number of sequential requests (default: 50)",
default=25,
help="Number of sequential requests (default: 25)",
)
parser.add_argument(
"-c",
Expand All @@ -1814,7 +1814,7 @@ def main():
help="Number of concurrent requests (default: 25)",
)
parser.add_argument(
"-w", "--workers", type=int, default=10, help="Number of concurrent workers (default: 10)"
"-w", "--workers", type=int, default=5, help="Number of concurrent workers (default: 5)"
)
parser.add_argument(
"--warmup", type=int, default=5, help="Number of warmup requests (default: 5)"
Expand Down
15 changes: 10 additions & 5 deletions benchmarks/libs/aiohttp_bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ async def request():
self.local_url, timeout=aiohttp.ClientTimeout(total=10)
) as resp:
assert 200 <= resp.status < 600, f"Got status {resp.status}"
await resp.read()
body = await resp.text()
self.validate_response_body(self.local_url, body)

return self.run_async_benchmark(request)

Expand All @@ -43,7 +44,8 @@ async def request():
self.remote_http_url, timeout=aiohttp.ClientTimeout(total=10)
) as resp:
assert 200 <= resp.status < 600, f"Got status {resp.status}"
await resp.read()
body = await resp.text()
self.validate_response_body(self.remote_http_url, body)

return self.run_async_benchmark(request)

Expand All @@ -54,7 +56,8 @@ async def request():
self.remote_https_url, timeout=aiohttp.ClientTimeout(total=10)
) as resp:
assert 200 <= resp.status < 600, f"Got status {resp.status}"
await resp.read()
body = await resp.text()
self.validate_response_body(self.remote_https_url, body)

return self.run_async_benchmark(request)

Expand All @@ -73,7 +76,8 @@ async def request():
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
assert 200 <= resp.status < 600, f"Got status {resp.status}"
await resp.read()
body = await resp.text()
self.validate_response_body(self.proxy_target_http, body)
except Exception:
pass

Expand All @@ -97,7 +101,8 @@ async def request():
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
assert 200 <= resp.status < 600, f"Got status {resp.status}"
await resp.read()
body = await resp.text()
self.validate_response_body(self.proxy_target_https, body)
except Exception:
pass

Expand Down
29 changes: 29 additions & 0 deletions benchmarks/libs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import time
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urlparse


class LibraryBenchmark(ABC):
Expand Down Expand Up @@ -41,6 +42,34 @@ def __init__(self, config):
self.proxy_target_http = config["proxy_target_http"]
self.proxy_target_https = config["proxy_target_https"]

def validate_response_body(self, url, body_text):
"""
Validate that the response body contains the hostname from the URL.

Args:
url: The requested URL (e.g., "http://httpbin.org/get")
body_text: The response body as text

Raises:
AssertionError: If the hostname is not found in the response body
"""
try:
parsed = urlparse(url)
hostname = parsed.hostname or parsed.netloc.split('@')[-1].split(':')[0]

if not hostname or hostname in ['127.0.0.1', 'localhost']:
# Skip validation for local URLs
return

# Check if hostname appears in the response body
assert hostname in body_text, (
f"Validation failed: hostname '{hostname}' not found in response body. "
f"This might indicate proxy misconfiguration or incorrect routing."
)
except (AttributeError, IndexError) as e:
# If URL parsing fails, skip validation
pass

@abstractmethod
def get_test_matrix(self):
"""Return list of (test_name, test_key) tuples for this library"""
Expand Down
8 changes: 8 additions & 0 deletions benchmarks/libs/curl_cffi_bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def run():

resp = curl_requests.get(self.local_url, verify=False)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.local_url, resp.text)

return self.run_sequential_benchmark("curl_cffi_seq_local", run)

Expand All @@ -49,6 +50,7 @@ def run():

resp = curl_requests.get(self.remote_http_url)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.remote_http_url, resp.text)

return self.run_sequential_benchmark("curl_cffi_seq_remote_http", run)

Expand All @@ -58,6 +60,7 @@ def run():

resp = curl_requests.get(self.remote_https_url, verify=False)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.remote_https_url, resp.text)

return self.run_sequential_benchmark("curl_cffi_seq_remote_https", run)

Expand All @@ -68,6 +71,7 @@ def run():
# curl_cffi uses HTTP/2 by default when available
resp = curl_requests.get(self.http2_url, verify=False, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.http2_url, resp.text)

return self.run_sequential_benchmark("curl_cffi_seq_remote_http2", run)

Expand All @@ -77,6 +81,7 @@ def run():

resp = curl_requests.get(self.local_url, verify=False)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.local_url, resp.text)

return self.run_concurrent_sync(run)

Expand All @@ -86,6 +91,7 @@ def run():

resp = curl_requests.get(self.remote_http_url)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.remote_http_url, resp.text)

return self.run_concurrent_sync(run)

Expand All @@ -95,6 +101,7 @@ def run():

resp = curl_requests.get(self.remote_https_url, verify=False)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.remote_https_url, resp.text)

return self.run_concurrent_sync(run)

Expand All @@ -104,6 +111,7 @@ def run():

resp = curl_requests.get(self.http2_url, verify=False, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.http2_url, resp.text)

return self.run_concurrent_sync(run)

Expand Down
21 changes: 21 additions & 0 deletions benchmarks/libs/httpmorph_bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def seq_local_http(self):
def run():
resp = client.get(self.local_url, verify=False)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.local_url, resp.text)

return self.run_sequential_benchmark("httpmorph_seq_local_http", run)

Expand All @@ -59,6 +60,7 @@ def seq_remote_http(self):
def run():
resp = client.get(self.remote_http_url, verify=False, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.remote_http_url, resp.text)

return self.run_sequential_benchmark("httpmorph_seq_remote_http", run)

Expand All @@ -68,6 +70,7 @@ def seq_remote_https(self):
def run():
resp = client.get(self.remote_https_url, verify=False, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.remote_https_url, resp.text)

return self.run_sequential_benchmark("httpmorph_seq_remote_https", run)

Expand All @@ -81,6 +84,7 @@ def seq_remote_http2(self):
def run():
resp = client.get(self.http2_url, verify=False, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.http2_url, resp.text)

return self.run_sequential_benchmark("httpmorph_seq_remote_http2", run)

Expand All @@ -95,6 +99,7 @@ def run():
self.proxy_target_http, proxy=self.proxy_url_http, verify=False, timeout=10
)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.proxy_target_http, resp.text)
except Exception:
# Proxy can be slow, accept timeout as valid
pass
Expand All @@ -121,6 +126,7 @@ def run():
self.proxy_target_https, proxy=self.proxy_url_https, verify=False, timeout=10
)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.proxy_target_https, resp.text)
except Exception:
# Proxy can be slow, accept timeout as valid
pass
Expand Down Expand Up @@ -149,6 +155,7 @@ def run():
self.proxy_target_https, proxy=self.proxy_url_https, verify=False, timeout=10
)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.proxy_target_https, resp.text)
except Exception:
pass

Expand All @@ -170,6 +177,7 @@ def conc_local_http(self):
def run():
resp = client.get(self.local_url, verify=False)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.local_url, resp.text)

return self.run_concurrent_sync(run)

Expand All @@ -179,6 +187,7 @@ def conc_remote_http(self):
def run():
resp = client.get(self.remote_http_url, verify=False, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.remote_http_url, resp.text)

return self.run_concurrent_sync(run)

Expand All @@ -188,6 +197,7 @@ def conc_remote_https(self):
def run():
resp = client.get(self.remote_https_url, verify=False, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.remote_https_url, resp.text)

return self.run_concurrent_sync(run)

Expand All @@ -200,6 +210,7 @@ def conc_remote_http2(self):
def run():
resp = client.get(self.http2_url, verify=False, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.http2_url, resp.text)

return self.run_concurrent_sync(run)

Expand All @@ -214,6 +225,7 @@ def run():
self.proxy_target_http, proxy=self.proxy_url_http, verify=False, timeout=10
)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.proxy_target_http, resp.text)
except Exception:
# Proxy can be slow or rate-limit concurrent connections, accept timeout as valid
pass
Expand All @@ -235,6 +247,7 @@ def run():
self.proxy_target_https, proxy=self.proxy_url_https, verify=False, timeout=10
)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.proxy_target_https, resp.text)
except Exception:
# Proxy can be slow or rate-limit concurrent connections, accept timeout as valid
pass
Expand All @@ -259,6 +272,7 @@ def run():
self.proxy_target_https, proxy=self.proxy_url_https, verify=False, timeout=10
)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.proxy_target_https, resp.text)
except Exception:
# HTTP/2 over proxy can be slow, accept timeout as valid
pass
Expand All @@ -280,6 +294,7 @@ async def run_batch():
async def request():
resp = await client.get(self.local_url, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.local_url, resp.text)

return await self.run_concurrent_async(request)

Expand All @@ -298,6 +313,7 @@ async def run_batch():
async def request():
resp = await client.get(self.remote_http_url, verify=False, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.remote_http_url, resp.text)

return await self.run_concurrent_async(request)

Expand All @@ -316,6 +332,7 @@ async def run_batch():
async def request():
resp = await client.get(self.remote_https_url, verify=False, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.remote_https_url, resp.text)

return await self.run_concurrent_async(request)

Expand All @@ -336,6 +353,7 @@ async def run_batch():
async def request():
resp = await client.get(self.http2_url, verify=False, timeout=10)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.http2_url, resp.text)

return await self.run_concurrent_async(request)

Expand All @@ -362,6 +380,7 @@ async def request():
timeout=10,
)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.proxy_target_http, resp.text)
except Exception:
# Proxy can be slow, accept timeout as valid
pass
Expand Down Expand Up @@ -392,6 +411,7 @@ async def request():
timeout=10,
)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.proxy_target_https, resp.text)
except Exception:
# Proxy can be slow, accept timeout as valid
pass
Expand Down Expand Up @@ -424,6 +444,7 @@ async def request():
timeout=10,
)
assert 200 <= resp.status_code < 600, f"Got status {resp.status_code}"
self.validate_response_body(self.proxy_target_https, resp.text)
except Exception:
# HTTP/2 over proxy can be slow, accept timeout as valid
pass
Expand Down
Loading
Loading