blob: e685d048c51c2d7840bed08bd66044a5d6dd64a3 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use iggy::prelude::{
AutoLogin as RustAutoLogin, Credentials as RustCredentials,
HttpClientConfig as RustHttpClientConfig, HttpClientConfigBuilder,
QuicClientConfig as RustQuicClientConfig, QuicClientConfigBuilder,
QuicClientReconnectionConfig as RustQuicClientReconnectionConfig,
TcpClientConfig as RustTcpClientConfig, TcpClientConfigBuilder,
TcpClientReconnectionConfig as RustTcpClientReconnectionConfig,
WebSocketClientConfig as RustWebSocketClientConfig, WebSocketClientConfigBuilder,
WebSocketClientReconnectionConfig as RustWebSocketClientReconnectionConfig,
WebSocketConfig as RustWebSocketFramingConfig,
};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::PyDelta;
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
use pyo3_stub_gen::impl_stub_type;
use secrecy::SecretString;
use std::fmt::Display;
use std::net::SocketAddr;
use std::sync::Arc;
use crate::duration::{
duration_repr, iggy_duration_to_py_delta, millis_repr, millis_to_py_delta,
py_delta_to_iggy_duration, py_delta_to_millis, reject_zero,
};
/// The credentials replayed by the client every time it (re)connects.
///
/// `IggyClient` only recovers a lost session when it has credentials to replay,
/// so a long-running consumer should pass one of the enabled variants.
#[gen_stub_pyclass]
#[pyclass(from_py_object)]
#[derive(Clone)]
pub struct AutoLogin {
pub(crate) inner: RustAutoLogin,
}
#[gen_stub_pymethods]
#[pymethods]
impl AutoLogin {
/// No automatic login. `login_user()` must be called by hand after every connect.
#[staticmethod]
fn disabled() -> Self {
Self {
inner: RustAutoLogin::Disabled,
}
}
/// Log in with the given username and password on every connect.
#[staticmethod]
fn username_password(username: String, password: String) -> Self {
Self {
inner: RustAutoLogin::Enabled(RustCredentials::UsernamePassword(
username,
SecretString::from(password),
)),
}
}
/// Log in with the given personal access token on every connect.
#[staticmethod]
fn personal_access_token(token: String) -> Self {
Self {
inner: RustAutoLogin::Enabled(RustCredentials::PersonalAccessToken(
SecretString::from(token),
)),
}
}
/// Whether automatic login is enabled.
#[getter]
fn enabled(&self) -> bool {
matches!(self.inner, RustAutoLogin::Enabled(_))
}
/// The username to log in with, or `None` for the disabled and token variants.
#[gen_stub(override_return_type(type_repr = "builtins.str | None"))]
#[getter]
fn username(&self) -> Option<String> {
match &self.inner {
RustAutoLogin::Enabled(RustCredentials::UsernamePassword(username, _)) => {
Some(username.clone())
}
_ => None,
}
}
fn __repr__(&self) -> String {
match &self.inner {
RustAutoLogin::Disabled => "AutoLogin.disabled()".to_owned(),
RustAutoLogin::Enabled(RustCredentials::UsernamePassword(username, _)) => {
format!("AutoLogin.username_password({username:?}, ...)")
}
RustAutoLogin::Enabled(RustCredentials::PersonalAccessToken(_)) => {
"AutoLogin.personal_access_token(...)".to_owned()
}
}
}
}
/// How the TCP client reconnects after the connection to the server is lost.
#[gen_stub_pyclass]
#[pyclass(from_py_object)]
#[derive(Clone)]
pub struct TcpReconnectionConfig {
pub(crate) inner: RustTcpClientReconnectionConfig,
}
#[gen_stub_pymethods]
#[pymethods]
impl TcpReconnectionConfig {
/// Constructs a reconnection policy.
///
/// Args:
/// enabled: Whether to reconnect at all. Defaults to enabled.
/// max_retries: Passes over the known endpoints after the first, or
/// `None` for unlimited; `0` still makes that first pass. One pass
/// tries the endpoint the client is on, the address it was
/// configured with, and every node the roster named, so this counts
/// passes rather than dials. Defaults
/// to unlimited, which means a call awaited while the server is
/// down never returns: `connect()`, `send_messages()` and
/// `poll_messages()` all wait inside the retry loop. Set a finite
/// number for request/reply style usage, so a call fails instead.
/// interval: Delay between passes. Defaults to 1 second. The first pass
/// runs at once when more than one endpoint is known.
/// reestablish_after: Cooldown before redialing the endpoint of the last
/// successful connection, measured from when it was established, so
/// a session that outlived the interval is redialed at once. Owed to
/// that endpoint alone. Defaults to 5 seconds.
///
/// Raises:
/// ValueError: If a duration is negative, if `max_retries` is outside the
/// range of an unsigned 32-bit integer, or if `interval` is zero.
/// OverflowError: If `max_retries` does not fit a signed 64-bit integer,
/// raised by the underlying conversion before this constructor runs.
#[new]
#[pyo3(signature = (*, enabled=None, max_retries=None, interval=None, reestablish_after=None))]
fn new(
#[gen_stub(override_type(type_repr = "builtins.bool | None"))] enabled: Option<bool>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] max_retries: Option<i64>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
interval: Option<Py<PyDelta>>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
reestablish_after: Option<Py<PyDelta>>,
) -> PyResult<Self> {
let defaults = RustTcpClientReconnectionConfig::default();
let enabled = enabled.unwrap_or(defaults.enabled);
let max_retries = max_retries
.map(|max_retries| u32_param(max_retries, "max_retries"))
.transpose()?;
let interval = interval
.as_ref()
.map(py_delta_to_iggy_duration)
.transpose()?
.map(|interval| reject_zero(interval, "interval"))
.transpose()?
.unwrap_or(defaults.interval);
Ok(Self {
inner: RustTcpClientReconnectionConfig {
enabled,
max_retries,
interval,
reestablish_after: reestablish_after
.as_ref()
.map(py_delta_to_iggy_duration)
.transpose()?
.unwrap_or(defaults.reestablish_after),
},
})
}
#[getter]
fn enabled(&self) -> bool {
self.inner.enabled
}
#[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
#[getter]
fn max_retries(&self) -> Option<u32> {
self.inner.max_retries
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
iggy_duration_to_py_delta(py, self.inner.interval.get())
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn reestablish_after<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
iggy_duration_to_py_delta(py, self.inner.reestablish_after)
}
fn __repr__(&self) -> String {
let max_retries = match self.inner.max_retries {
Some(max_retries) => max_retries.to_string(),
None => "None".to_owned(),
};
format!(
"TcpReconnectionConfig(enabled={}, max_retries={max_retries}, interval={}, reestablish_after={})",
python_bool(self.inner.enabled),
duration_repr(self.inner.interval.get()),
duration_repr(self.inner.reestablish_after),
)
}
}
/// Configuration for the TCP transport, accepted by `IggyClient(...)`.
///
/// Every field is keyword-only and optional.
#[gen_stub_pyclass]
#[pyclass(from_py_object)]
#[derive(Clone)]
pub struct TcpConfig {
inner: Arc<RustTcpClientConfig>,
}
impl TcpConfig {
/// The configuration in the shape `TcpClient::create` expects.
pub(crate) fn client_config(&self) -> Arc<RustTcpClientConfig> {
self.inner.clone()
}
}
#[gen_stub_pymethods]
#[pymethods]
impl TcpConfig {
/// Constructs a TCP configuration.
///
/// Args:
/// server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8090`.
/// auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`.
/// reconnection: Reconnection policy. Defaults to `TcpReconnectionConfig()`.
/// heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds.
/// tls_enabled: Whether to connect over TLS. Defaults to disabled.
/// tls_domain: Domain to validate the certificate against. Empty means it is
/// taken from `server_address`.
/// tls_ca_file: Path to the CA file for TLS. Read only when `tls_enabled`
/// and `tls_validate_certificate` are both on; with either one off it
/// is kept but never consulted, so pairing it with
/// `tls_validate_certificate=False` pins nothing.
/// tls_validate_certificate: Whether to validate the server certificate.
/// Defaults to validating. Disabling this accepts any certificate the
/// server presents, including self-signed and mismatched ones, and
/// takes precedence over `tls_ca_file`; intended for local development
/// only.
/// nodelay: Disable the Nagle algorithm for the TCP socket. Defaults to
/// leaving it on.
///
/// Raises:
/// ValueError: If `server_address` is not a valid `host:port` pair, if a
/// duration is negative, or if `heartbeat_interval` is zero.
#[new]
#[pyo3(signature = (
*,
server_address=None,
auto_login=None,
reconnection=None,
heartbeat_interval=None,
tls_enabled=None,
tls_domain=None,
tls_ca_file=None,
tls_validate_certificate=None,
nodelay=None,
))]
#[allow(clippy::too_many_arguments)]
fn new(
#[gen_stub(override_type(type_repr = "builtins.str | None"))] server_address: Option<
String,
>,
#[gen_stub(override_type(type_repr = "AutoLogin | None"))] auto_login: Option<AutoLogin>,
#[gen_stub(override_type(type_repr = "TcpReconnectionConfig | None"))] reconnection: Option<
TcpReconnectionConfig,
>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
heartbeat_interval: Option<Py<PyDelta>>,
#[gen_stub(override_type(type_repr = "builtins.bool | None"))] tls_enabled: Option<bool>,
#[gen_stub(override_type(type_repr = "builtins.str | None"))] tls_domain: Option<String>,
#[gen_stub(override_type(type_repr = "builtins.str | None"))] tls_ca_file: Option<String>,
#[gen_stub(override_type(type_repr = "builtins.bool | None"))]
tls_validate_certificate: Option<bool>,
#[gen_stub(override_type(type_repr = "builtins.bool | None"))] nodelay: Option<bool>,
) -> PyResult<Self> {
// The builder starts from `TcpClientConfig::default()`, and its `build()`
// trims and validates the address whether or not one was set here.
let mut builder = TcpClientConfigBuilder::new();
if let Some(server_address) = server_address {
builder = builder.with_server_address(server_address);
}
let mut inner = builder
.build()
.map_err(|e| invalid_address("server_address", e))?;
if let Some(auto_login) = auto_login {
inner.auto_login = auto_login.inner;
}
if let Some(reconnection) = reconnection {
inner.reconnection = reconnection.inner;
}
if let Some(heartbeat_interval) = heartbeat_interval {
inner.heartbeat_interval = reject_zero(
py_delta_to_iggy_duration(&heartbeat_interval)?,
"heartbeat_interval",
)?;
}
if let Some(tls_enabled) = tls_enabled {
inner.tls_enabled = tls_enabled;
}
if let Some(tls_domain) = tls_domain {
inner.tls_domain = tls_domain;
}
if tls_ca_file.is_some() {
inner.tls_ca_file = tls_ca_file;
}
if let Some(tls_validate_certificate) = tls_validate_certificate {
inner.tls_validate_certificate = tls_validate_certificate;
}
if let Some(nodelay) = nodelay {
inner.nodelay = nodelay;
}
Ok(Self {
inner: Arc::new(inner),
})
}
#[getter]
fn server_address(&self) -> String {
self.inner.server_address.clone()
}
#[getter]
fn auto_login(&self) -> AutoLogin {
AutoLogin {
inner: self.inner.auto_login.clone(),
}
}
#[getter]
fn reconnection(&self) -> TcpReconnectionConfig {
TcpReconnectionConfig {
inner: self.inner.reconnection.clone(),
}
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn heartbeat_interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
iggy_duration_to_py_delta(py, self.inner.heartbeat_interval.get())
}
#[getter]
fn tls_enabled(&self) -> bool {
self.inner.tls_enabled
}
#[getter]
fn tls_domain(&self) -> String {
self.inner.tls_domain.clone()
}
#[gen_stub(override_return_type(type_repr = "builtins.str | None"))]
#[getter]
fn tls_ca_file(&self) -> Option<String> {
self.inner.tls_ca_file.clone()
}
#[getter]
fn tls_validate_certificate(&self) -> bool {
self.inner.tls_validate_certificate
}
#[getter]
fn nodelay(&self) -> bool {
self.inner.nodelay
}
fn __repr__(&self) -> String {
let tls_ca_file = match &self.inner.tls_ca_file {
Some(tls_ca_file) => format!("{tls_ca_file:?}"),
None => "None".to_owned(),
};
format!(
"TcpConfig(server_address={:?}, auto_login={}, reconnection={}, heartbeat_interval={}, tls_enabled={}, tls_domain={:?}, tls_ca_file={tls_ca_file}, tls_validate_certificate={}, nodelay={})",
self.inner.server_address,
self.auto_login().__repr__(),
self.reconnection().__repr__(),
duration_repr(self.inner.heartbeat_interval.get()),
python_bool(self.inner.tls_enabled),
self.inner.tls_domain,
python_bool(self.inner.tls_validate_certificate),
python_bool(self.inner.nodelay),
)
}
}
/// How the QUIC client reconnects after the connection to the server is lost.
#[gen_stub_pyclass]
#[pyclass(from_py_object)]
#[derive(Clone)]
pub struct QuicReconnectionConfig {
pub(crate) inner: RustQuicClientReconnectionConfig,
}
#[gen_stub_pymethods]
#[pymethods]
impl QuicReconnectionConfig {
/// Constructs a reconnection policy.
///
/// Args:
/// enabled: Whether to reconnect at all. Defaults to enabled.
/// max_retries: Redials of the configured server address after the first
/// attempt, or `None` for unlimited; `0` still makes that first
/// attempt. Unlike the TCP transport, QUIC redials the one address
/// it was configured with rather than walking a cluster roster, so
/// this counts dials. Defaults to unlimited, which means a call
/// awaited while the server is down never returns: `connect()`
/// waits inside the retry loop, as do `send_messages()` and
/// `poll_messages()` once auto-login is configured. Set a finite
/// number for request/reply style usage, so a call fails instead.
/// interval: Delay before each redial. Defaults to 1 second.
/// reestablish_after: Cooldown before redialing after a previously
/// successful connection, measured from when it was established, so
/// a session that outlived the interval is redialed at once.
/// Defaults to 5 seconds.
///
/// Raises:
/// ValueError: If a duration is negative, if `max_retries` is outside the
/// range of an unsigned 32-bit integer, or if `interval` is zero.
/// OverflowError: If `max_retries` does not fit a signed 64-bit integer,
/// raised by the underlying conversion before this constructor runs.
#[new]
#[pyo3(signature = (*, enabled=None, max_retries=None, interval=None, reestablish_after=None))]
fn new(
#[gen_stub(override_type(type_repr = "builtins.bool | None"))] enabled: Option<bool>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] max_retries: Option<i64>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
interval: Option<Py<PyDelta>>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
reestablish_after: Option<Py<PyDelta>>,
) -> PyResult<Self> {
let defaults = RustQuicClientReconnectionConfig::default();
let enabled = enabled.unwrap_or(defaults.enabled);
let max_retries = max_retries
.map(|max_retries| u32_param(max_retries, "max_retries"))
.transpose()?;
let interval = interval
.as_ref()
.map(py_delta_to_iggy_duration)
.transpose()?
.map(|interval| reject_zero(interval, "interval"))
.transpose()?
.unwrap_or(defaults.interval);
Ok(Self {
inner: RustQuicClientReconnectionConfig {
enabled,
max_retries,
interval,
reestablish_after: reestablish_after
.as_ref()
.map(py_delta_to_iggy_duration)
.transpose()?
.unwrap_or(defaults.reestablish_after),
},
})
}
#[getter]
fn enabled(&self) -> bool {
self.inner.enabled
}
#[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
#[getter]
fn max_retries(&self) -> Option<u32> {
self.inner.max_retries
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
iggy_duration_to_py_delta(py, self.inner.interval.get())
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn reestablish_after<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
iggy_duration_to_py_delta(py, self.inner.reestablish_after)
}
fn __repr__(&self) -> String {
let max_retries = match self.inner.max_retries {
Some(max_retries) => max_retries.to_string(),
None => "None".to_owned(),
};
format!(
"QuicReconnectionConfig(enabled={}, max_retries={max_retries}, interval={}, reestablish_after={})",
python_bool(self.inner.enabled),
duration_repr(self.inner.interval.get()),
duration_repr(self.inner.reestablish_after),
)
}
}
/// quinn clamps `TransportConfig::initial_mtu` up to this floor rather than
/// rejecting a smaller value, so `QuicConfig` rejects it instead: otherwise the
/// getter would read back a value that is not the one actually in effect.
const QUINN_MIN_INITIAL_MTU: u16 = 1200;
/// Configuration for the QUIC transport, accepted by `IggyClient(...)`.
///
/// Every field is keyword-only and optional.
#[gen_stub_pyclass]
#[pyclass(from_py_object)]
#[derive(Clone)]
pub struct QuicConfig {
inner: Arc<RustQuicClientConfig>,
}
impl QuicConfig {
/// The configuration in the shape `QuicClient::create` expects.
pub(crate) fn client_config(&self) -> Arc<RustQuicClientConfig> {
self.inner.clone()
}
}
#[gen_stub_pymethods]
#[pymethods]
impl QuicConfig {
/// Constructs a QUIC configuration.
///
/// Args:
/// server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8080`.
/// client_address: `host:port` to bind the local UDP socket to. Defaults to
/// `127.0.0.1:0`, which binds to any available port. That exact value,
/// passed or defaulted, binds `[::1]:0` instead when `server_address`
/// resolves to IPv6, so the socket in use may not be the address read
/// back here. Any other value binds as given.
/// server_name: Server name used for the QUIC/TLS handshake. Defaults to
/// `localhost`.
/// auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`.
/// reconnection: Reconnection policy. Defaults to `QuicReconnectionConfig()`.
/// heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds.
/// response_buffer_size: Size of the response buffer in bytes. Defaults to 10 MB.
/// max_concurrent_bidi_streams: Maximum number of concurrent bidirectional
/// streams. Defaults to 10,000.
/// datagram_send_buffer_size: Size of the datagram send buffer in bytes.
/// Defaults to 100,000.
/// initial_mtu: Initial MTU in bytes. Defaults to 1200.
/// send_window: Send window size in bytes. Defaults to 100,000.
/// receive_window: Receive window size in bytes. Defaults to 100,000.
/// keep_alive_interval: Interval between QUIC keep-alive pings, or a zero
/// duration to disable them. Defaults to 5 seconds.
/// max_idle_timeout: How long the connection tolerates silence before it is
/// considered dead, or a zero duration to use quinn's own default (30
/// seconds) instead, since `configure()` skips the setter entirely when
/// zero. Defaults to 10 seconds.
/// validate_certificate: Whether to validate the server certificate. Defaults
/// to disabled; only the TCP transport validates by default.
///
/// Raises:
/// ValueError: If `server_address` or `client_address` is not a valid
/// `host:port` pair, if a duration is negative, if
/// `heartbeat_interval` is zero, if `keep_alive_interval` or
/// `max_idle_timeout` is not a whole number of milliseconds, if
/// `initial_mtu` is below quinn's minimum of 1200, or if a numeric
/// field is outside the range of its underlying wire type.
/// OverflowError: If a numeric field does not fit a signed 64-bit integer,
/// raised by the underlying conversion before this constructor runs.
#[new]
#[pyo3(signature = (
*,
server_address=None,
client_address=None,
server_name=None,
auto_login=None,
reconnection=None,
heartbeat_interval=None,
response_buffer_size=None,
max_concurrent_bidi_streams=None,
datagram_send_buffer_size=None,
initial_mtu=None,
send_window=None,
receive_window=None,
keep_alive_interval=None,
max_idle_timeout=None,
validate_certificate=None,
))]
#[allow(clippy::too_many_arguments)]
fn new(
#[gen_stub(override_type(type_repr = "builtins.str | None"))] server_address: Option<
String,
>,
#[gen_stub(override_type(type_repr = "builtins.str | None"))] client_address: Option<
String,
>,
#[gen_stub(override_type(type_repr = "builtins.str | None"))] server_name: Option<String>,
#[gen_stub(override_type(type_repr = "AutoLogin | None"))] auto_login: Option<AutoLogin>,
#[gen_stub(override_type(type_repr = "QuicReconnectionConfig | None"))]
reconnection: Option<QuicReconnectionConfig>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
heartbeat_interval: Option<Py<PyDelta>>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] response_buffer_size: Option<
i64,
>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))]
max_concurrent_bidi_streams: Option<i64>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))]
datagram_send_buffer_size: Option<i64>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] initial_mtu: Option<i64>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] send_window: Option<i64>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] receive_window: Option<i64>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
keep_alive_interval: Option<Py<PyDelta>>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
max_idle_timeout: Option<Py<PyDelta>>,
#[gen_stub(override_type(type_repr = "builtins.bool | None"))] validate_certificate: Option<
bool,
>,
) -> PyResult<Self> {
// The builder starts from `QuicClientConfig::default()`, and its `build()`
// trims and validates the server address whether or not one was set here.
let mut builder = QuicClientConfigBuilder::new();
if let Some(server_address) = server_address {
builder = builder.with_server_address(server_address);
}
let mut inner = builder
.build()
.map_err(|e| invalid_address("server_address", e))?;
if let Some(client_address) = client_address {
// Trimmed like the server address, but otherwise kept verbatim rather
// than re-serialized from the parsed `SocketAddr`: `QuicClient::create`
// compares this against the literal default to decide whether to bind
// an IPv6 socket for an IPv6 server, and a rewritten string would not
// match.
let client_address = client_address.trim();
client_address
.parse::<SocketAddr>()
.map_err(|e| invalid_address("client_address", e))?;
inner.client_address = client_address.to_owned();
}
if let Some(server_name) = server_name {
inner.server_name = server_name;
}
if let Some(auto_login) = auto_login {
inner.auto_login = auto_login.inner;
}
if let Some(reconnection) = reconnection {
inner.reconnection = reconnection.inner;
}
if let Some(heartbeat_interval) = heartbeat_interval {
inner.heartbeat_interval = reject_zero(
py_delta_to_iggy_duration(&heartbeat_interval)?,
"heartbeat_interval",
)?;
}
if let Some(response_buffer_size) = response_buffer_size {
inner.response_buffer_size = u64_param(response_buffer_size, "response_buffer_size")?;
}
if let Some(max_concurrent_bidi_streams) = max_concurrent_bidi_streams {
inner.max_concurrent_bidi_streams =
varint_param(max_concurrent_bidi_streams, "max_concurrent_bidi_streams")?;
}
if let Some(datagram_send_buffer_size) = datagram_send_buffer_size {
inner.datagram_send_buffer_size =
u64_param(datagram_send_buffer_size, "datagram_send_buffer_size")?;
}
if let Some(initial_mtu) = initial_mtu {
let initial_mtu = u16_param(initial_mtu, "initial_mtu")?;
if initial_mtu < QUINN_MIN_INITIAL_MTU {
return Err(PyValueError::new_err(format!(
"'initial_mtu' must be at least {QUINN_MIN_INITIAL_MTU}; quinn silently \
raises anything smaller to that floor, so the getter would no longer \
match the value actually in effect"
)));
}
inner.initial_mtu = initial_mtu;
}
if let Some(send_window) = send_window {
inner.send_window = u64_param(send_window, "send_window")?;
}
if let Some(receive_window) = receive_window {
inner.receive_window = varint_param(receive_window, "receive_window")?;
}
if let Some(keep_alive_interval) = keep_alive_interval {
inner.keep_alive_interval =
py_delta_to_millis(&keep_alive_interval, "keep_alive_interval")?;
}
if let Some(max_idle_timeout) = max_idle_timeout {
inner.max_idle_timeout = py_delta_to_millis(&max_idle_timeout, "max_idle_timeout")?;
}
if let Some(validate_certificate) = validate_certificate {
inner.validate_certificate = validate_certificate;
}
Ok(Self {
inner: Arc::new(inner),
})
}
#[getter]
fn server_address(&self) -> String {
self.inner.server_address.clone()
}
#[getter]
fn client_address(&self) -> String {
self.inner.client_address.clone()
}
#[getter]
fn server_name(&self) -> String {
self.inner.server_name.clone()
}
#[getter]
fn auto_login(&self) -> AutoLogin {
AutoLogin {
inner: self.inner.auto_login.clone(),
}
}
#[getter]
fn reconnection(&self) -> QuicReconnectionConfig {
QuicReconnectionConfig {
inner: self.inner.reconnection.clone(),
}
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn heartbeat_interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
iggy_duration_to_py_delta(py, self.inner.heartbeat_interval.get())
}
#[gen_stub(override_return_type(type_repr = "builtins.int"))]
#[getter]
fn response_buffer_size(&self) -> u64 {
self.inner.response_buffer_size
}
#[gen_stub(override_return_type(type_repr = "builtins.int"))]
#[getter]
fn max_concurrent_bidi_streams(&self) -> u64 {
self.inner.max_concurrent_bidi_streams
}
#[gen_stub(override_return_type(type_repr = "builtins.int"))]
#[getter]
fn datagram_send_buffer_size(&self) -> u64 {
self.inner.datagram_send_buffer_size
}
#[gen_stub(override_return_type(type_repr = "builtins.int"))]
#[getter]
fn initial_mtu(&self) -> u16 {
self.inner.initial_mtu
}
#[gen_stub(override_return_type(type_repr = "builtins.int"))]
#[getter]
fn send_window(&self) -> u64 {
self.inner.send_window
}
#[gen_stub(override_return_type(type_repr = "builtins.int"))]
#[getter]
fn receive_window(&self) -> u64 {
self.inner.receive_window
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn keep_alive_interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
millis_to_py_delta(py, self.inner.keep_alive_interval)
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn max_idle_timeout<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
millis_to_py_delta(py, self.inner.max_idle_timeout)
}
#[getter]
fn validate_certificate(&self) -> bool {
self.inner.validate_certificate
}
fn __repr__(&self) -> String {
format!(
"QuicConfig(server_address={:?}, client_address={:?}, server_name={:?}, auto_login={}, reconnection={}, heartbeat_interval={}, response_buffer_size={}, max_concurrent_bidi_streams={}, datagram_send_buffer_size={}, initial_mtu={}, send_window={}, receive_window={}, keep_alive_interval={}, max_idle_timeout={}, validate_certificate={})",
self.inner.server_address,
self.inner.client_address,
self.inner.server_name,
self.auto_login().__repr__(),
self.reconnection().__repr__(),
duration_repr(self.inner.heartbeat_interval.get()),
self.inner.response_buffer_size,
self.inner.max_concurrent_bidi_streams,
self.inner.datagram_send_buffer_size,
self.inner.initial_mtu,
self.inner.send_window,
self.inner.receive_window,
millis_repr(self.inner.keep_alive_interval),
millis_repr(self.inner.max_idle_timeout),
python_bool(self.inner.validate_certificate),
)
}
}
/// Configuration for the HTTP transport, accepted by `IggyClient(...)`.
///
/// Every field is keyword-only and optional.
///
/// There is no `AutoLogin` and no reconnection policy, and `connect()` does not
/// dial: it only starts the heartbeat, so `login_user(...)` has to follow it.
///
/// HTTP is single-consumer only. `consumer_group(...)` fails with
/// `Feature is unavailable`, and so does a `Consumer.Group(...)` poll unless it
/// names an explicit `partition_id`. With one, the consumer kind is not carried
/// on the HTTP wire, so the poll is served as an ordinary consumer named after
/// the group, with no membership, no partition assignment, and no rebalancing
/// behind it. Pass `Consumer.Single(...)` explicitly.
#[gen_stub_pyclass]
#[pyclass(from_py_object)]
#[derive(Clone)]
pub struct HttpConfig {
inner: Arc<RustHttpClientConfig>,
}
impl HttpConfig {
/// The configuration in the shape `HttpClient::create` expects.
pub(crate) fn client_config(&self) -> Arc<RustHttpClientConfig> {
self.inner.clone()
}
}
#[gen_stub_pymethods]
#[pymethods]
impl HttpConfig {
/// Constructs an HTTP configuration.
///
/// Args:
/// api_url: Base URL of the Iggy HTTP API, as `scheme://host[:port]`
/// only - no path, query, fragment, or credentials. Defaults to
/// `http://127.0.0.1:3000`.
/// retries: Number of retries to perform on transient errors, each one
/// replaying the full request (including its body) via automatic
/// middleware. Defaults to 3. Delivery is therefore at-least-once:
/// if the original request actually committed but its response
/// was lost (e.g. to a timeout), a retried call applies the same
/// operation again. Set to 0 to disable automatic replay and match
/// the other transports, which surface the failure instead of
/// silently resending.
/// jwt: JWT token for A2A (Agent-to-Agent) authentication. Defaults to
/// `None`. Stored trimmed, since a token read from a file carries a
/// trailing newline that the `Authorization` header value rejects.
/// Rejected if empty or whitespace-only: accepting it would make
/// `has_jwt` report `True` while every call still fails
/// `Unauthenticated`.
/// heartbeat_interval: Interval between the client's liveness probes
/// (a bare `GET /ping`). Defaults to 5 seconds. Unlike TCP/QUIC,
/// HTTP has no persistent connection or session for this to keep
/// alive; it only proves the server is reachable.
///
/// Raises:
/// ValueError: If `api_url` is not a valid URL, if `retries` is outside
/// the range of an unsigned 32-bit integer, if `jwt` is empty or
/// whitespace-only, if a duration is negative, or if
/// `heartbeat_interval` is zero.
/// OverflowError: If `retries` does not fit a signed 64-bit integer,
/// raised by the underlying conversion before this constructor runs.
#[new]
#[pyo3(signature = (*, api_url=None, retries=None, jwt=None, heartbeat_interval=None))]
fn new(
#[gen_stub(override_type(type_repr = "builtins.str | None"))] api_url: Option<String>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] retries: Option<i64>,
#[gen_stub(override_type(type_repr = "builtins.str | None"))] jwt: Option<String>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
heartbeat_interval: Option<Py<PyDelta>>,
) -> PyResult<Self> {
// The builder starts from `HttpClientConfig::default()`, and its `build()`
// trims and validates the API URL whether or not one was set here.
let mut builder = HttpClientConfigBuilder::new();
if let Some(api_url) = api_url {
builder = builder.with_api_url(api_url);
}
let mut inner = builder
.build()
.map_err(|e| PyValueError::new_err(format!("'api_url' is not a valid URL: {e}")))?;
if let Some(retries) = retries {
inner.retries = u32_param(retries, "retries")?;
}
if let Some(jwt) = jwt {
let jwt = jwt.trim();
if jwt.is_empty() {
return Err(PyValueError::new_err(
"'jwt' must not be empty or whitespace-only",
));
}
inner.jwt = Some(jwt.to_owned());
}
if let Some(heartbeat_interval) = heartbeat_interval {
inner.heartbeat_interval = reject_zero(
py_delta_to_iggy_duration(&heartbeat_interval)?,
"heartbeat_interval",
)?;
}
Ok(Self {
inner: Arc::new(inner),
})
}
#[getter]
fn api_url(&self) -> String {
self.inner.api_url.clone()
}
#[getter]
fn retries(&self) -> u32 {
self.inner.retries
}
/// Whether a JWT is configured, without exposing the token itself.
#[getter]
fn has_jwt(&self) -> bool {
self.inner.jwt.is_some()
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn heartbeat_interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
iggy_duration_to_py_delta(py, self.inner.heartbeat_interval.get())
}
fn __repr__(&self) -> String {
let jwt = if self.inner.jwt.is_some() {
"..."
} else {
"None"
};
format!(
"HttpConfig(api_url={:?}, retries={}, jwt={jwt}, heartbeat_interval={})",
self.inner.api_url,
self.inner.retries,
duration_repr(self.inner.heartbeat_interval.get()),
)
}
}
/// How the WebSocket client reconnects after the connection to the server is lost.
#[gen_stub_pyclass]
#[pyclass(from_py_object)]
#[derive(Clone)]
pub struct WebSocketReconnectionConfig {
pub(crate) inner: RustWebSocketClientReconnectionConfig,
}
#[gen_stub_pymethods]
#[pymethods]
impl WebSocketReconnectionConfig {
/// Constructs a reconnection policy.
///
/// Args:
/// enabled: Whether to reconnect at all. Defaults to enabled.
/// max_retries: Redials of the configured server address after the first
/// attempt, or `None` for unlimited; `0` still makes that first
/// attempt. Unlike the TCP transport, WebSocket redials the one
/// address it was configured with rather than walking a cluster
/// roster, so this counts dials. Defaults to unlimited, which means
/// a call awaited while the server is down never returns:
/// `connect()` waits inside the retry loop, as do `send_messages()`
/// and `poll_messages()` once auto-login is configured. Set a finite
/// number for request/reply style usage, so a call fails instead.
/// interval: Delay before each redial. Defaults to 1 second.
/// reestablish_after: Cooldown before redialing after a previously
/// successful connection, measured from when it was established, so
/// a session that outlived the interval is redialed at once. Applied
/// from the first redial onward, not to the initial connect.
/// Defaults to 5 seconds.
///
/// Raises:
/// ValueError: If a duration is negative, if `max_retries` is outside the
/// range of an unsigned 32-bit integer, or if `interval` is zero.
/// OverflowError: If `max_retries` does not fit a signed 64-bit integer,
/// raised by the underlying conversion before this constructor runs.
#[new]
#[pyo3(signature = (*, enabled=None, max_retries=None, interval=None, reestablish_after=None))]
fn new(
#[gen_stub(override_type(type_repr = "builtins.bool | None"))] enabled: Option<bool>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] max_retries: Option<i64>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
interval: Option<Py<PyDelta>>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
reestablish_after: Option<Py<PyDelta>>,
) -> PyResult<Self> {
let defaults = RustWebSocketClientReconnectionConfig::default();
let enabled = enabled.unwrap_or(defaults.enabled);
let max_retries = max_retries
.map(|max_retries| u32_param(max_retries, "max_retries"))
.transpose()?;
let interval = interval
.as_ref()
.map(py_delta_to_iggy_duration)
.transpose()?
.map(|interval| reject_zero(interval, "interval"))
.transpose()?
.unwrap_or(defaults.interval);
Ok(Self {
inner: RustWebSocketClientReconnectionConfig {
enabled,
max_retries,
interval,
reestablish_after: reestablish_after
.as_ref()
.map(py_delta_to_iggy_duration)
.transpose()?
.unwrap_or(defaults.reestablish_after),
},
})
}
#[getter]
fn enabled(&self) -> bool {
self.inner.enabled
}
#[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
#[getter]
fn max_retries(&self) -> Option<u32> {
self.inner.max_retries
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
iggy_duration_to_py_delta(py, self.inner.interval.get())
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn reestablish_after<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
iggy_duration_to_py_delta(py, self.inner.reestablish_after)
}
fn __repr__(&self) -> String {
let max_retries = match self.inner.max_retries {
Some(max_retries) => max_retries.to_string(),
None => "None".to_owned(),
};
format!(
"WebSocketReconnectionConfig(enabled={}, max_retries={max_retries}, interval={}, reestablish_after={})",
python_bool(self.inner.enabled),
duration_repr(self.inner.interval.get()),
duration_repr(self.inner.reestablish_after),
)
}
}
/// Frame- and buffer-level options passed through to the underlying WebSocket
/// implementation, accepted by `WebSocketConfig`'s `framing` argument.
///
/// Every field is keyword-only and optional; unset fields fall back to the
/// underlying WebSocket library's own defaults.
#[gen_stub_pyclass]
#[pyclass(from_py_object)]
#[derive(Clone)]
pub struct WebSocketFramingConfig {
pub(crate) inner: RustWebSocketFramingConfig,
}
#[gen_stub_pymethods]
#[pymethods]
impl WebSocketFramingConfig {
/// Constructs a WebSocket framing configuration.
///
/// Args:
/// read_buffer_size: Read buffer size in bytes. Defaults to 128 KiB.
/// write_buffer_size: Write buffer size in bytes. Defaults to 128 KiB.
/// max_write_buffer_size: Maximum write buffer size in bytes. Defaults to
/// unbounded, which reads back as the largest value a pointer-sized
/// unsigned integer holds rather than as `None`.
/// max_message_size: Maximum message size in bytes, or an explicit `None`
/// to lift the limit entirely. Omitting the argument is not the same
/// as passing `None`: it keeps the underlying default of 64 MiB.
/// Lifting the limit lets a peer queue an arbitrarily large message
/// in memory, so prefer a finite value.
/// max_frame_size: Maximum frame size in bytes, or an explicit `None` to
/// lift the limit entirely. Omitting the argument keeps the
/// underlying default of 16 MiB, with the same caveat as
/// `max_message_size`.
/// accept_unmasked_frames: Whether to accept unmasked frames. Defaults to
/// `False`; clients should typically keep this off for RFC compliance.
///
/// Raises:
/// ValueError: If a numeric field is outside the range of a pointer-sized
/// unsigned integer, or if `max_write_buffer_size` does not come out
/// greater than `write_buffer_size`. tungstenite enforces the same
/// invariant with an `assert!` at connect time, which would otherwise
/// surface as an unrecoverable Rust panic instead of a `ValueError`.
/// OverflowError: If a numeric field does not fit a signed 128-bit integer,
/// raised by the underlying conversion before this constructor runs.
#[new]
#[pyo3(signature = (
*,
read_buffer_size=None,
write_buffer_size=None,
max_write_buffer_size=None,
max_message_size=64 << 20,
max_frame_size=16 << 20,
accept_unmasked_frames=None,
))]
fn new(
#[gen_stub(override_type(type_repr = "builtins.int | None"))] read_buffer_size: Option<
i128,
>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] write_buffer_size: Option<
i128,
>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] max_write_buffer_size: Option<
i128,
>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] max_message_size: Option<
i128,
>,
#[gen_stub(override_type(type_repr = "builtins.int | None"))] max_frame_size: Option<i128>,
#[gen_stub(override_type(type_repr = "builtins.bool | None"))]
accept_unmasked_frames: Option<bool>,
) -> PyResult<Self> {
let mut inner = RustWebSocketFramingConfig::default();
if let Some(read_buffer_size) = read_buffer_size {
inner.read_buffer_size = Some(usize_param(read_buffer_size, "read_buffer_size")?);
}
if let Some(write_buffer_size) = write_buffer_size {
inner.write_buffer_size = Some(usize_param(write_buffer_size, "write_buffer_size")?);
}
if let Some(max_write_buffer_size) = max_write_buffer_size {
inner.max_write_buffer_size =
Some(usize_param(max_write_buffer_size, "max_write_buffer_size")?);
}
// Assigned unconditionally, unlike the buffer sizes above: `None` here
// means "no limit", and pyo3 cannot tell an omitted argument from an
// explicit `None` on its own. The signature defaults carry the
// underlying limits instead, so omission lands on `Some(default)` and
// only an explicit `None` reaches this as `None`.
inner.max_message_size = max_message_size
.map(|max_message_size| usize_param(max_message_size, "max_message_size"))
.transpose()?;
inner.max_frame_size = max_frame_size
.map(|max_frame_size| usize_param(max_frame_size, "max_frame_size"))
.transpose()?;
if let Some(accept_unmasked_frames) = accept_unmasked_frames {
inner.accept_unmasked_frames = accept_unmasked_frames;
}
if let (Some(write_buffer_size), Some(max_write_buffer_size)) =
(inner.write_buffer_size, inner.max_write_buffer_size)
&& max_write_buffer_size <= write_buffer_size
{
return Err(PyValueError::new_err(format!(
"'max_write_buffer_size' ({max_write_buffer_size}) must be greater than \
'write_buffer_size' ({write_buffer_size})"
)));
}
Ok(Self { inner })
}
#[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
#[getter]
fn read_buffer_size(&self) -> Option<usize> {
self.inner.read_buffer_size
}
#[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
#[getter]
fn write_buffer_size(&self) -> Option<usize> {
self.inner.write_buffer_size
}
#[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
#[getter]
fn max_write_buffer_size(&self) -> Option<usize> {
self.inner.max_write_buffer_size
}
#[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
#[getter]
fn max_message_size(&self) -> Option<usize> {
self.inner.max_message_size
}
#[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
#[getter]
fn max_frame_size(&self) -> Option<usize> {
self.inner.max_frame_size
}
#[getter]
fn accept_unmasked_frames(&self) -> bool {
self.inner.accept_unmasked_frames
}
fn __repr__(&self) -> String {
let optional_usize = |value: Option<usize>| match value {
Some(value) => value.to_string(),
None => "None".to_owned(),
};
format!(
"WebSocketFramingConfig(read_buffer_size={}, write_buffer_size={}, max_write_buffer_size={}, max_message_size={}, max_frame_size={}, accept_unmasked_frames={})",
optional_usize(self.inner.read_buffer_size),
optional_usize(self.inner.write_buffer_size),
optional_usize(self.inner.max_write_buffer_size),
optional_usize(self.inner.max_message_size),
optional_usize(self.inner.max_frame_size),
python_bool(self.inner.accept_unmasked_frames),
)
}
}
/// Configuration for the WebSocket transport, accepted by `IggyClient(...)`.
///
/// Every field is keyword-only and optional.
#[gen_stub_pyclass]
#[pyclass(from_py_object)]
#[derive(Clone)]
pub struct WebSocketConfig {
inner: Arc<RustWebSocketClientConfig>,
}
impl WebSocketConfig {
/// The configuration in the shape `WebSocketClient::create` expects.
pub(crate) fn client_config(&self) -> Arc<RustWebSocketClientConfig> {
self.inner.clone()
}
}
#[gen_stub_pymethods]
#[pymethods]
impl WebSocketConfig {
/// Constructs a WebSocket configuration.
///
/// Args:
/// server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8092`.
/// auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`.
/// reconnection: Reconnection policy. Defaults to `WebSocketReconnectionConfig()`.
/// heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds.
/// framing: Frame- and buffer-level options. Defaults to `WebSocketFramingConfig()`.
/// tls_enabled: Whether to connect over TLS. Defaults to disabled.
/// tls_domain: Domain to validate the certificate against. Defaults to
/// `localhost`. Empty means it is taken from the IP `server_address`
/// resolves to.
/// tls_ca_file: Path to the CA file for TLS. Read only when `tls_enabled`
/// and `tls_validate_certificate` are both on; with either one off it
/// is kept but never consulted, so pairing it with
/// `tls_validate_certificate=False` pins nothing.
/// tls_validate_certificate: Whether to validate the server certificate.
/// Defaults to `False`; only the TCP transport validates by default.
/// Disabling this accepts any certificate the server presents,
/// including self-signed and mismatched ones, and takes precedence
/// over `tls_ca_file`.
///
/// Raises:
/// ValueError: If `server_address` is not a valid `host:port` pair, if a
/// duration is negative, or if `heartbeat_interval` is zero.
#[new]
#[pyo3(signature = (
*,
server_address=None,
auto_login=None,
reconnection=None,
heartbeat_interval=None,
framing=None,
tls_enabled=None,
tls_domain=None,
tls_ca_file=None,
tls_validate_certificate=None,
))]
#[allow(clippy::too_many_arguments)]
fn new(
#[gen_stub(override_type(type_repr = "builtins.str | None"))] server_address: Option<
String,
>,
#[gen_stub(override_type(type_repr = "AutoLogin | None"))] auto_login: Option<AutoLogin>,
#[gen_stub(override_type(type_repr = "WebSocketReconnectionConfig | None"))]
reconnection: Option<WebSocketReconnectionConfig>,
#[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
heartbeat_interval: Option<Py<PyDelta>>,
#[gen_stub(override_type(type_repr = "WebSocketFramingConfig | None"))] framing: Option<
WebSocketFramingConfig,
>,
#[gen_stub(override_type(type_repr = "builtins.bool | None"))] tls_enabled: Option<bool>,
#[gen_stub(override_type(type_repr = "builtins.str | None"))] tls_domain: Option<String>,
#[gen_stub(override_type(type_repr = "builtins.str | None"))] tls_ca_file: Option<String>,
#[gen_stub(override_type(type_repr = "builtins.bool | None"))]
tls_validate_certificate: Option<bool>,
) -> PyResult<Self> {
// The builder starts from `WebSocketClientConfig::default()`, and its
// `build()` trims and validates the address whether or not one was set here.
let mut builder = WebSocketClientConfigBuilder::new();
if let Some(server_address) = server_address {
builder = builder.with_server_address(server_address);
}
let mut inner = builder
.build()
.map_err(|e| invalid_address("server_address", e))?;
if let Some(auto_login) = auto_login {
inner.auto_login = auto_login.inner;
}
if let Some(reconnection) = reconnection {
inner.reconnection = reconnection.inner;
}
if let Some(heartbeat_interval) = heartbeat_interval {
inner.heartbeat_interval = reject_zero(
py_delta_to_iggy_duration(&heartbeat_interval)?,
"heartbeat_interval",
)?;
}
if let Some(framing) = framing {
inner.ws_config = framing.inner;
}
if let Some(tls_enabled) = tls_enabled {
inner.tls_enabled = tls_enabled;
}
if let Some(tls_domain) = tls_domain {
inner.tls_domain = tls_domain;
}
if tls_ca_file.is_some() {
inner.tls_ca_file = tls_ca_file;
}
if let Some(tls_validate_certificate) = tls_validate_certificate {
inner.tls_validate_certificate = tls_validate_certificate;
}
Ok(Self {
inner: Arc::new(inner),
})
}
#[getter]
fn server_address(&self) -> String {
self.inner.server_address.clone()
}
#[getter]
fn auto_login(&self) -> AutoLogin {
AutoLogin {
inner: self.inner.auto_login.clone(),
}
}
#[getter]
fn reconnection(&self) -> WebSocketReconnectionConfig {
WebSocketReconnectionConfig {
inner: self.inner.reconnection.clone(),
}
}
#[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))]
#[getter]
fn heartbeat_interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
iggy_duration_to_py_delta(py, self.inner.heartbeat_interval.get())
}
#[getter]
fn framing(&self) -> WebSocketFramingConfig {
WebSocketFramingConfig {
inner: self.inner.ws_config.clone(),
}
}
#[getter]
fn tls_enabled(&self) -> bool {
self.inner.tls_enabled
}
#[getter]
fn tls_domain(&self) -> String {
self.inner.tls_domain.clone()
}
#[gen_stub(override_return_type(type_repr = "builtins.str | None"))]
#[getter]
fn tls_ca_file(&self) -> Option<String> {
self.inner.tls_ca_file.clone()
}
#[getter]
fn tls_validate_certificate(&self) -> bool {
self.inner.tls_validate_certificate
}
fn __repr__(&self) -> String {
let tls_ca_file = match &self.inner.tls_ca_file {
Some(tls_ca_file) => format!("{tls_ca_file:?}"),
None => "None".to_owned(),
};
format!(
"WebSocketConfig(server_address={:?}, auto_login={}, reconnection={}, heartbeat_interval={}, framing={}, tls_enabled={}, tls_domain={:?}, tls_ca_file={tls_ca_file}, tls_validate_certificate={})",
self.inner.server_address,
self.auto_login().__repr__(),
self.reconnection().__repr__(),
duration_repr(self.inner.heartbeat_interval.get()),
self.framing().__repr__(),
python_bool(self.inner.tls_enabled),
self.inner.tls_domain,
python_bool(self.inner.tls_validate_certificate),
)
}
}
fn python_bool(value: bool) -> &'static str {
if value { "True" } else { "False" }
}
/// Rejects an address that is not a valid `host:port`, naming the argument it
/// came from: neither the builder's error nor `SocketAddr`'s mentions which one.
fn invalid_address(parameter: &str, error: impl Display) -> PyErr {
PyValueError::new_err(format!("'{parameter}' is not a valid 'host:port': {error}"))
}
/// Converts a Python int to the unsigned 32-bit integer `max_retries`/`retries`
/// expect, naming the parameter in the error so a caller can tell which
/// argument was out of range. A value too large even for `i64` still raises
/// pyo3's own unnamed `OverflowError` before this ever runs.
fn u32_param(value: i64, parameter: &str) -> PyResult<u32> {
u32::try_from(value).map_err(|_| {
PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", u32::MAX))
})
}
/// Converts a Python int to the unsigned 64-bit integer a QUIC transport
/// field expects, naming the parameter in the error so a caller can tell
/// which argument was out of range. The bound in the message is `i64::MAX`
/// rather than `u64::MAX` because pyo3 extracts the argument as an `i64`
/// first: anything above that never reaches here, raising `OverflowError`
/// on the way in. Every one of these fields is a buffer or window size, so
/// the unreachable half of the range has no practical use.
fn u64_param(value: i64, parameter: &str) -> PyResult<u64> {
u64::try_from(value).map_err(|_| {
PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", i64::MAX))
})
}
/// Converts a Python int to the unsigned 16-bit integer `initial_mtu` expects.
fn u16_param(value: i64, parameter: &str) -> PyResult<u16> {
u16::try_from(value).map_err(|_| {
PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", u16::MAX))
})
}
/// Converts a Python int to a `u64` that also fits `quinn::VarInt` (max
/// `2^62 - 1`), which `max_concurrent_bidi_streams` and `receive_window` are
/// narrowed into when the connection is configured. A `u64` in range for
/// `u64::MAX` but not `VarInt::MAX` would otherwise only fail there, as an
/// opaque `RuntimeError` instead of a `ValueError` naming the argument.
fn varint_param(value: i64, parameter: &str) -> PyResult<u64> {
const VARINT_MAX: u64 = (1u64 << 62) - 1;
let value = u64_param(value, parameter)?;
if value > VARINT_MAX {
return Err(PyValueError::new_err(format!(
"'{parameter}' must be between 0 and {VARINT_MAX}"
)));
}
Ok(value)
}
/// Converts a Python int to the unsigned pointer-sized integer a WebSocket
/// framing field expects, naming the parameter in the error so a caller can
/// tell which argument was out of range. Extracted as an `i128` rather than an
/// `i64` so the whole `usize` range survives the way in: `max_write_buffer_size`
/// defaults to `usize::MAX`, which an `i64` parameter would refuse to take back
/// with an unnamed `OverflowError`, breaking `eval(repr(config))`.
fn usize_param(value: i128, parameter: &str) -> PyResult<usize> {
usize::try_from(value).map_err(|_| {
PyValueError::new_err(format!(
"'{parameter}' must be between 0 and {}",
usize::MAX
))
})
}
/// What `IggyClient(...)` accepts: a bare `host:port`, a full `TcpConfig`, a
/// `QuicConfig` for the QUIC transport, an `HttpConfig` for the HTTP transport,
/// or a `WebSocketConfig` for the WebSocket transport.
#[derive(FromPyObject)]
pub enum PyClientConfig {
#[pyo3(transparent)]
Tcp(TcpConfig),
#[pyo3(transparent)]
Quic(QuicConfig),
#[pyo3(transparent)]
Http(HttpConfig),
#[pyo3(transparent)]
WebSocket(WebSocketConfig),
#[pyo3(transparent, annotation = "str")]
ServerAddress(String),
}
impl_stub_type!(PyClientConfig = TcpConfig | QuicConfig | HttpConfig | WebSocketConfig | String);
#[cfg(test)]
mod tests {
use super::*;
/// Mirrors the literal in `WebSocketFramingConfig::new`'s signature.
const DEFAULT_MAX_MESSAGE_SIZE: usize = 64 << 20;
/// Mirrors the literal in `WebSocketFramingConfig::new`'s signature.
const DEFAULT_MAX_FRAME_SIZE: usize = 16 << 20;
/// The signature defaults have to be literals for the generated stub to stay
/// valid Python, so nothing but this test stops them drifting from the SDK
/// (and so from tungstenite) on a dependency bump.
#[test]
fn defaults_should_match_the_sdk() {
let defaults = RustWebSocketFramingConfig::default();
assert_eq!(
defaults.max_message_size,
Some(DEFAULT_MAX_MESSAGE_SIZE),
"'max_message_size' drifted from the SDK, update the literal in \
WebSocketFramingConfig::new's signature too"
);
assert_eq!(
defaults.max_frame_size,
Some(DEFAULT_MAX_FRAME_SIZE),
"'max_frame_size' drifted from the SDK, update the literal in \
WebSocketFramingConfig::new's signature too"
);
assert!(
defaults.write_buffer_size.is_some(),
"'write_buffer_size' lost its default, so the write buffer invariant \
check in WebSocketFramingConfig::new would stop running"
);
assert!(
defaults.max_write_buffer_size.is_some(),
"'max_write_buffer_size' lost its default, so the write buffer \
invariant check in WebSocketFramingConfig::new would stop running"
);
}
}