| // 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" |
| ); |
| } |
| } |