Document Python dependency and make integration tests conditional
- Update README.md to mention Python 3 requirement for integration tests - Make rate limiting test skip gracefully if Python 3 is not available - Move and rename test helper script to tests/gemini_test_client.py - Update test to use new script path - Improve test documentation and error handling
This commit is contained in:
parent
1ef0f97ebf
commit
ad84bf187d
3 changed files with 211 additions and 2 deletions
|
|
@ -85,4 +85,6 @@ Access with a Gemini client like Lagrange at `gemini://yourdomain.com/`.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run `cargo test` for unit tests. Fix warnings before commits.
|
Run `cargo test` for the full test suite, which includes integration tests that require Python 3.
|
||||||
|
|
||||||
|
**Note**: Integration tests use Python 3 for Gemini protocol validation. If Python 3 is not available, integration tests will be skipped automatically.
|
||||||
|
|
|
||||||
195
tests/gemini_test_client.py
Executable file
195
tests/gemini_test_client.py
Executable file
|
|
@ -0,0 +1,195 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Gemini Test Client
|
||||||
|
|
||||||
|
A simple Gemini protocol client for testing Gemini servers.
|
||||||
|
Used by integration tests to validate server behavior.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 tests/gemini_test_client.py --url gemini://example.com/ --timeout 10
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
import multiprocessing
|
||||||
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
"""Parse command line arguments"""
|
||||||
|
parser = argparse.ArgumentParser(description='Test Gemini rate limiting with concurrent requests')
|
||||||
|
parser.add_argument('--limit', type=int, default=3,
|
||||||
|
help='Number of concurrent requests to send (default: 3)')
|
||||||
|
parser.add_argument('--host', default='localhost',
|
||||||
|
help='Server host (default: localhost)')
|
||||||
|
parser.add_argument('--port', type=int, default=1965,
|
||||||
|
help='Server port (default: 1965)')
|
||||||
|
parser.add_argument('--delay', type=float, default=0.1,
|
||||||
|
help='Delay between request start and connection close (default: 0.1s)')
|
||||||
|
parser.add_argument('--timeout', type=float, default=5.0,
|
||||||
|
help='Socket timeout in seconds (default: 5.0)')
|
||||||
|
parser.add_argument('--url', default='gemini://localhost/big-file.mkv',
|
||||||
|
help='Gemini URL to request (default: gemini://localhost/big-file.mkv)')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if args.limit < 1:
|
||||||
|
parser.error("Limit must be at least 1")
|
||||||
|
if args.limit > 10000:
|
||||||
|
parser.error("Limit too high (max 10000 for safety)")
|
||||||
|
if args.delay < 0:
|
||||||
|
parser.error("Delay must be non-negative")
|
||||||
|
if args.timeout <= 0:
|
||||||
|
parser.error("Timeout must be positive")
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
def send_gemini_request(host, port, url, delay, timeout):
|
||||||
|
"""Send one Gemini request with proper error handling"""
|
||||||
|
try:
|
||||||
|
# Create SSL context
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
# Connect with timeout
|
||||||
|
sock = socket.create_connection((host, port), timeout=timeout)
|
||||||
|
ssl_sock = context.wrap_socket(sock, server_hostname=host)
|
||||||
|
|
||||||
|
# Send request
|
||||||
|
request = f"{url}\r\n".encode('utf-8')
|
||||||
|
ssl_sock.send(request)
|
||||||
|
|
||||||
|
# Read response with timeout
|
||||||
|
ssl_sock.settimeout(timeout)
|
||||||
|
response = ssl_sock.recv(1024)
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
return "Error: Empty response"
|
||||||
|
|
||||||
|
status = response.decode('utf-8', errors='ignore').split('\r\n')[0]
|
||||||
|
|
||||||
|
# Keep connection alive briefly if requested
|
||||||
|
if delay > 0:
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
ssl_sock.close()
|
||||||
|
return status
|
||||||
|
|
||||||
|
except socket.timeout:
|
||||||
|
return "Error: Timeout"
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
return "Error: Connection refused"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run concurrent requests"""
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
if args.limit == 1:
|
||||||
|
print("Testing single request (debug mode)...")
|
||||||
|
start_time = time.time()
|
||||||
|
result = send_gemini_request(args.host, args.port, args.url, args.delay, args.timeout)
|
||||||
|
end_time = time.time()
|
||||||
|
duration = end_time - start_time
|
||||||
|
print(f"Result: {result}")
|
||||||
|
print(".2f")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Testing rate limiting with {args.limit} concurrent requests (using multiprocessing)...")
|
||||||
|
print(f"Server: {args.host}:{args.port}")
|
||||||
|
print(f"URL: {args.url}")
|
||||||
|
print(f"Delay: {args.delay}s, Timeout: {args.timeout}s")
|
||||||
|
print()
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Use ProcessPoolExecutor for true parallelism (bypasses GIL)
|
||||||
|
results = []
|
||||||
|
max_workers = min(args.limit, multiprocessing.cpu_count() * 4) # Limit workers to avoid system overload
|
||||||
|
|
||||||
|
with ProcessPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
futures = [
|
||||||
|
executor.submit(send_gemini_request, args.host, args.port,
|
||||||
|
args.url, args.delay, args.timeout)
|
||||||
|
for _ in range(args.limit)
|
||||||
|
]
|
||||||
|
|
||||||
|
for future in as_completed(futures):
|
||||||
|
results.append(future.result())
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
|
# Analyze results
|
||||||
|
status_counts = {}
|
||||||
|
connection_refused = 0
|
||||||
|
timeouts = 0
|
||||||
|
other_errors = []
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
if "Connection refused" in result:
|
||||||
|
connection_refused += 1
|
||||||
|
elif "Timeout" in result:
|
||||||
|
timeouts += 1
|
||||||
|
elif result.startswith("Error"):
|
||||||
|
other_errors.append(result)
|
||||||
|
else:
|
||||||
|
status_counts[result] = status_counts.get(result, 0) + 1
|
||||||
|
|
||||||
|
# Print results
|
||||||
|
print("Results:")
|
||||||
|
for status, count in sorted(status_counts.items()):
|
||||||
|
print(f" {status}: {count}")
|
||||||
|
if connection_refused > 0:
|
||||||
|
print(f" Connection refused: {connection_refused} (server overloaded)")
|
||||||
|
if timeouts > 0:
|
||||||
|
print(f" Timeouts: {timeouts} (server unresponsive)")
|
||||||
|
if other_errors:
|
||||||
|
print(f" Other errors: {len(other_errors)}")
|
||||||
|
for error in other_errors[:3]:
|
||||||
|
print(f" {error}")
|
||||||
|
if len(other_errors) > 3:
|
||||||
|
print(f" ... and {len(other_errors) - 3} more")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(".2f")
|
||||||
|
|
||||||
|
# Success criteria for rate limiting
|
||||||
|
success_20 = status_counts.get("20 application/octet-stream", 0)
|
||||||
|
rate_limited_41 = status_counts.get("41 Server unavailable", 0)
|
||||||
|
total_successful = success_20 + rate_limited_41 + connection_refused
|
||||||
|
total_processed = total_successful + timeouts
|
||||||
|
|
||||||
|
print(f"\nAnalysis:")
|
||||||
|
print(f" Total requests sent: {args.limit}")
|
||||||
|
print(f" Successfully processed: {total_successful}")
|
||||||
|
print(f" Timeouts (server unresponsive): {timeouts}")
|
||||||
|
|
||||||
|
if args.limit == 1:
|
||||||
|
# Single request should succeed
|
||||||
|
if success_20 == 1 and timeouts == 0:
|
||||||
|
print("✅ PASS: Single request works correctly")
|
||||||
|
else:
|
||||||
|
print("❌ FAIL: Single request failed")
|
||||||
|
elif rate_limited_41 > 0 and success_20 > 0:
|
||||||
|
# We have both successful responses and 41 rate limited responses
|
||||||
|
print("✅ PASS: Rate limiting detected!")
|
||||||
|
print(f" {success_20} requests succeeded")
|
||||||
|
print(f" {rate_limited_41} requests rate limited with 41 response")
|
||||||
|
print(" Mixed results indicate rate limiting is working correctly")
|
||||||
|
elif success_20 == args.limit and timeouts == 0:
|
||||||
|
# All requests succeeded
|
||||||
|
print("⚠️ All requests succeeded - rate limiting may not be triggered")
|
||||||
|
print(" This could mean:")
|
||||||
|
print(" - Requests are not truly concurrent")
|
||||||
|
print(" - Processing is too fast for overlap")
|
||||||
|
print(" - Need longer delays or more concurrent requests")
|
||||||
|
else:
|
||||||
|
print("❓ UNCLEAR: Check server logs and test parameters")
|
||||||
|
print(" May need to adjust --limit, delays, or server configuration")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
@ -2,6 +2,10 @@ use std::process::Command;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rate_limiting_with_concurrent_requests() {
|
fn test_rate_limiting_with_concurrent_requests() {
|
||||||
|
if !python_available() {
|
||||||
|
println!("Skipping rate limiting test: Python 3 not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Create temp config with max_concurrent_requests = 1
|
// Create temp config with max_concurrent_requests = 1
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
let config_path = temp_dir.join("pollux_test_config.toml");
|
let config_path = temp_dir.join("pollux_test_config.toml");
|
||||||
|
|
@ -35,7 +39,7 @@ fn test_rate_limiting_with_concurrent_requests() {
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
let handle = std::thread::spawn(|| {
|
let handle = std::thread::spawn(|| {
|
||||||
Command::new("python3")
|
Command::new("python3")
|
||||||
.arg("tmp/test_rate_limit_python.py")
|
.arg("tests/gemini_test_client.py")
|
||||||
.arg("--limit")
|
.arg("--limit")
|
||||||
.arg("1")
|
.arg("1")
|
||||||
.arg("--host")
|
.arg("--host")
|
||||||
|
|
@ -77,3 +81,11 @@ fn test_rate_limiting_with_concurrent_requests() {
|
||||||
assert_eq!(success_count, 1);
|
assert_eq!(success_count, 1);
|
||||||
assert_eq!(rate_limited_count, 4);
|
assert_eq!(rate_limited_count, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn python_available() -> bool {
|
||||||
|
std::process::Command::new("python3")
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.map(|output| output.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue