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
9 changes: 8 additions & 1 deletion packages/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,17 @@ impl CatsuClient {
/// api_keys: Optional dict of provider names to API keys
/// max_retries: Maximum number of retry attempts (default: 3)
/// timeout: Request timeout in seconds (default: 30)
/// proxy: Optional HTTP/HTTPS proxy URL (e.g. "http://proxy.corp.com:8080").
/// When None (default), reqwest respects HTTP_PROXY/HTTPS_PROXY env vars.
/// ca_cert: Optional PEM-encoded CA certificate string for custom certificate authorities
#[new]
#[pyo3(signature = (api_keys=None, max_retries=None, timeout=None))]
#[pyo3(signature = (api_keys=None, max_retries=None, timeout=None, proxy=None, ca_cert=None))]
pub fn new(
api_keys: Option<HashMap<String, String>>,
max_retries: Option<u32>,
timeout: Option<u64>,
proxy: Option<String>,
ca_cert: Option<String>,
) -> PyResult<Self> {
let mut config = HttpConfig::default();
if let Some(retries) = max_retries {
Expand All @@ -172,6 +177,8 @@ impl CatsuClient {
if let Some(secs) = timeout {
config.timeout_secs = secs;
}
config.proxy = proxy.filter(|s| !s.is_empty());
config.ca_cert_pem = ca_cert.filter(|s| !s.is_empty());

let inner = if let Some(keys) = api_keys {
RustClient::with_api_keys_and_config(keys, config)
Expand Down
64 changes: 64 additions & 0 deletions packages/python/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import pytest


def test_client_with_proxy():
"""Test that Client accepts a proxy parameter."""
from catsu import Client

client = Client(proxy="http://proxy.example.com:8080")
assert client is not None


def test_client_with_invalid_ca_cert():
"""Test that Client raises an error for invalid CA certificate."""
from catsu import Client

with pytest.raises(RuntimeError):
Client(ca_cert="not a valid certificate")


def test_client_with_invalid_proxy():
"""Test that Client raises an error for invalid proxy URL."""
from catsu import Client

with pytest.raises(RuntimeError):
Client(proxy="not-a-valid-url")


def test_client_with_valid_ca_cert_format():
"""Test that Client accepts a valid PEM format CA certificate.

Note: This uses a syntactically valid but fake certificate.
The client may fail later when making requests, but construction should work.
"""
from catsu import Client

# A minimal self-signed test certificate (valid PEM format)
test_cert = """-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKHBfpegPjMCMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnVu
dXNlZDAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM
BnVudXNlZDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC6fGQKtQ3u3tLGDNnM8Jv2
vHNJJnKJkf8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6j
AgMBAAEwDQYJKoZIhvcNAQELBQADQQBN6V7t8Hy8cWJxmXNvh8J6jJJ8J8J6jJJ8
J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6jJJ8J8J6
-----END CERTIFICATE-----"""

# This may raise due to invalid cert content, but should at least parse the PEM format
try:
client = Client(ca_cert=test_cert)
assert client is not None
except RuntimeError as e:
# Accept errors about invalid cert content, but not about unexpected params
assert "certificate" in str(e).lower() or "pem" in str(e).lower() or "base64" in str(e).lower()


def test_client_with_proxy_and_other_options():
"""Test that proxy can be combined with other options."""
from catsu import Client

client = Client(
proxy="http://proxy.example.com:8080",
max_retries=5,
timeout=60,
)
assert client is not None
55 changes: 52 additions & 3 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ pub struct HttpConfig {
pub max_backoff_ms: u64,
/// Request timeout in seconds.
pub timeout_secs: u64,
/// HTTP/HTTPS proxy URL.
pub proxy: Option<String>,
/// PEM-encoded CA certificate.
pub ca_cert_pem: Option<String>,
}

impl Default for HttpConfig {
Expand All @@ -28,6 +32,8 @@ impl Default for HttpConfig {
initial_backoff_ms: 100,
max_backoff_ms: 30_000,
timeout_secs: 30,
proxy: None,
ca_cert_pem: None,
}
}
}
Expand All @@ -42,10 +48,21 @@ pub struct HttpClient {
impl HttpClient {
/// Create a new HTTP client with the given configuration.
pub fn new(config: HttpConfig) -> Result<Self, ClientError> {
let client = Client::builder()
let mut builder = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.pool_max_idle_per_host(10)
.build()?;
.pool_max_idle_per_host(10);

if let Some(ref proxy_url) = config.proxy {
let proxy = reqwest::Proxy::all(proxy_url)?;
builder = builder.proxy(proxy);
}

if let Some(ref pem) = config.ca_cert_pem {
let cert = reqwest::Certificate::from_pem(pem.as_bytes())?;
builder = builder.add_root_certificate(cert);
}

let client = builder.build()?;

Ok(Self { client, config })
}
Expand Down Expand Up @@ -175,4 +192,36 @@ mod tests {
assert!(!is_retryable_status(401));
assert!(!is_retryable_status(404));
}

#[test]
fn test_config_with_proxy_and_ca_cert() {
let config = HttpConfig {
proxy: Some("http://proxy.example.com:8080".to_string()),
ca_cert_pem: Some(
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----".to_string(),
),
..HttpConfig::default()
};
assert_eq!(
config.proxy,
Some("http://proxy.example.com:8080".to_string())
);
assert!(config.ca_cert_pem.is_some());
assert!(config.ca_cert_pem.unwrap().contains("BEGIN CERTIFICATE"));
}

#[test]
Comment on lines +196 to +213

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Rust tests only verify configuration storage but don't test actual HttpClient construction with proxy and CA certificate. Consider adding a test that constructs an HttpClient with these configurations to ensure they are properly applied to the underlying reqwest client:

#[test]
fn test_http_client_with_proxy() {
    let config = HttpConfig {
        proxy: Some("http://proxy.example.com:8080".to_string()),
        ..HttpConfig::default()
    };
    let client = HttpClient::new(config);
    assert!(client.is_ok());
}

This would catch any issues with the builder pattern or reqwest integration.

Copilot uses AI. Check for mistakes.
fn test_default_config_has_no_proxy_or_ca_cert() {
let config = HttpConfig::default();
assert!(config.proxy.is_none());
assert!(config.ca_cert_pem.is_none());
}

#[test]
fn test_http_client_new_with_proxy() {
let mut config = HttpConfig::default();
config.proxy = Some("http://proxy.example.com:8080".to_string());
let result = HttpClient::new(config);
assert!(result.is_ok());
}
}