Skip to content

feat(python): expose TCP client configuration - #3776

Open
ethanlin01x wants to merge 24 commits into
apache:masterfrom
ethanlin01x:feat/python-tcp-config
Open

feat(python): expose TCP client configuration#3776
ethanlin01x wants to merge 24 commits into
apache:masterfrom
ethanlin01x:feat/python-tcp-config

Conversation

@ethanlin01x

@ethanlin01x ethanlin01x commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR address?

Closes #3742

Rationale

The Python binding accepts only a bare server address, so reconnection and auto-login cannot be configured from Python, which makes the SDK's session recovery (#2880) unreachable: a session dropped by a server restart surfaces as Unauthenticated on the next call and applications have to hand-roll connect/login/probe retry loops.

What changed?

IggyClient(...) only took host:port, with AutoLogin::Disabled hardcoded and the reconnection policy untunable. It now also accepts a TcpConfig mirroring the Rust TcpClientConfig (auto_login, reconnection, heartbeat_interval, the TLS options, nodelay), keyword-only, with every unset field falling back to the Rust default instead of a value duplicated in the binding. Durations are datetime.timedelta validated at construction, which also fixes the pre-existing conversion that cast a negative timedelta into a huge unsigned duration. This changes shipped behavior: a negative timedelta passed to create_topic / update_topic (message_expiry), the consumer(...) intervals, or AutoCommit.Interval(...) previously became a near-u64::MAX duration and now raises ValueError. Type names follow the maintainer's guidance in the issue (TcpConfig, TcpReconnectionConfig); scope is TCP only, and the bare-address constructor and from_connection_string are unchanged.

Local Execution

  • Passed
  • Pre-commit hooks ran

AI Usage

  1. Which tools? Claude
  2. Scope of usage? help generate and review this PR
  3. How did you verify the generated code works correctly? Integration tests against a real server prove credentials are replayed on connect without a manual login_user().
  4. Can you explain every line of the code if asked? Yes, all the changes are checked by the human.

IggyClient accepted only a server address, so auto-login and reconnection
tuning were unreachable from Python. Without credentials to replay, the
SDK's own session recovery never fires and a dropped session surfaces as
Unauthenticated on the next call, leaving the application to hand-roll a
connect/login/probe loop.

TcpConfig mirrors TcpClientConfig field for field and is accepted by the
IggyClient constructor alongside the existing address string. AutoLogin
carries the credentials without exposing them back to Python, and
TcpReconnectionConfig carries the retry policy.

Credentials is re-exported from the SDK prelude because AutoLogin::Enabled
cannot be constructed without naming it.

Closes apache#3742
Round-trip every field through the getters so a default that drifts from
the Rust SDK is caught, and assert that neither the password nor a personal
access token comes back out of repr.

The auto-login tests are the point of the configuration: a privileged call
succeeds without a manual login_user() when credentials are configured, and
fails without them.
The existing examples all reach for a connection string, which leaves the
new config types undiscoverable. This one configures auto-login and
reconnection directly and never calls login_user, so the recovery the
credentials unlock is visible: restart the server while it runs and the
client picks up where it left off.
The README pointed only at the examples directory, so the configuration
surface stayed invisible to anyone reading the package page on PyPI.
A negative timedelta normalizes to negative days plus positive seconds,
so the old conversion summed to a negative i32 and cast it to u64,
turning interval=timedelta(seconds=-1) into u64::MAX seconds: the config
constructed fine and the client then slept forever on reconnect. Days
arithmetic also overflowed i32 beyond ~68 years, and the reverse
conversion stuffed everything into the seconds argument so such values
could not read back. Conversion is now fallible, rejects negative input
with ValueError at construction, computes in i64, and splits days on the
way out. The AutoCommit conversion becomes TryFrom to carry the error.

The boolean constructor defaults were literals in the pyo3 signature, so
a change to a Rust default would silently not propagate. They are now
Option arguments that fall back to TcpClientConfig::default(), the same
way the durations already did.
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.69118% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.38%. Comparing base (0a23e16) to head (9150053).

Files with missing lines Patch % Lines
foreign/python/src/config.rs 97.07% 6 Missing ⚠️
foreign/python/src/topic.rs 66.66% 2 Missing ⚠️
foreign/python/src/duration.rs 96.55% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3776      +/-   ##
============================================
- Coverage     76.40%   76.38%   -0.02%     
  Complexity     1046     1046              
============================================
  Files          1334     1336       +2     
  Lines        165228   165452     +224     
  Branches     137583   137660      +77     
============================================
+ Hits         126242   126387     +145     
+ Misses        35271    35261      -10     
- Partials       3715     3804      +89     
Components Coverage Δ
Rust Core 76.39% <ø> (-0.02%) ⬇️
Java SDK 63.67% <ø> (ø)
C# SDK 71.13% <ø> (-1.14%) ⬇️
Python SDK 89.48% <96.69%> (+1.33%) ⬆️
PHP SDK 82.97% <ø> (ø)
Node SDK 96.36% <ø> (+0.08%) ⬆️
Go SDK 43.08% <ø> (ø)
Files with missing lines Coverage Δ
foreign/python/src/client.rs 99.84% <100.00%> (+0.62%) ⬆️
foreign/python/src/consumer.rs 82.21% <100.00%> (+1.10%) ⬆️
foreign/python/src/lib.rs 100.00% <100.00%> (ø)
foreign/python/src/receive_message.rs 88.37% <ø> (ø)
foreign/python/src/send_message.rs 72.60% <ø> (ø)
foreign/python/src/duration.rs 96.55% <96.55%> (ø)
foreign/python/src/topic.rs 82.27% <66.66%> (-1.06%) ⬇️
foreign/python/src/config.rs 97.07% <97.07%> (ø)

... and 51 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ethanlin01x
ethanlin01x marked this pull request as ready for review July 29, 2026 18:32
@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Jul 29, 2026
@ethanlin01x
ethanlin01x marked this pull request as draft July 29, 2026 18:35
@github-actions github-actions Bot removed the S-waiting-on-review PR is waiting on a reviewer label Jul 29, 2026
conftest auto-marked every module as integration, so tests explicitly
marked unit could not be selected with -m "not integration" even though
they need no server. The auto-mark now skips them.

New cases pin the duration boundaries (negative rejected, zero legal,
beyond the i32 seconds range round-trips) and the README claim that a
connection string and TcpConfig reach the same behavior.
The snippet ended with a top-level await; every other sample in the repo
wraps in asyncio.run, so paste-and-run failed on the only snippet a PyPI
reader sees first.
@ethanlin01x
ethanlin01x force-pushed the feat/python-tcp-config branch from 2b6c6ee to b3190f4 Compare July 29, 2026 18:39
@ethanlin01x
ethanlin01x marked this pull request as ready for review July 29, 2026 19:01
@ethanlin01x

Copy link
Copy Markdown
Contributor Author

/ready

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Jul 29, 2026

@slbotbm slbotbm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good. Mostly cosmetic changes. One thing though: in the docs, you are declaring the thrown errors as PyValueError and similar types. These are rust types which the python user will not see. Also, there are references to the rust sdk in public docs. Please remove them. Our modelled users are python users, who would not know anything about rust.

Comment thread examples/python/client-configuration/main.py Outdated
Comment thread examples/python/README.md
Comment thread foreign/python/README.md Outdated
Comment thread foreign/python/README.md Outdated
@github-actions github-actions Bot added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Jul 29, 2026
Comment thread foreign/python/src/duration.rs Outdated
Comment on lines +43 to +49
let micros = duration.as_micros();
let total_seconds = micros / 1_000_000;
let days = i32::try_from(total_seconds / 86_400).map_err(|_| {
PyErr::new::<pyo3::exceptions::PyOverflowError, _>(
"duration does not fit into a datetime.timedelta",
)
})?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IggyDuration::as_micros() is a truncating cast (core/common/src/utils/duration.rs:94):

pub fn as_micros(&self) -> u64 {
    self.duration.as_micros() as u64
}

py_delta_to_iggy_duration accepts timedelta(days=999_999_999) — 8.64e13 seconds, which fits a Duration — but that is ~8.64e19 µs against a u64::MAX of ~1.84e19, so the value wraps here and the getter returns a wrong duration rather than raising.

That also leaves the PyOverflowError below unreachable: a post-wrap value caps at ~213,503 days, well under i32::MAX, so the try_from can never fail.

Reading the u128 directly fixes both:

let micros = duration.get_duration().as_micros();

The existing try_from guards start doing real work once the input isn't pre-wrapped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 92048cb by reading the std Duration directly, plus a timedelta(days=999_999_999) round-trip test pinning it.

Comment on lines +111 to +118
def test_negative_duration_is_rejected(
self,
construct: Callable[[timedelta], TcpReconnectionConfig],
negative: timedelta,
):
"""Test that a negative duration fails at construction, not at connect."""
with pytest.raises(ValueError, match="negative"):
construct(negative)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The negative-duration rejection also changes methods that already shipped: create_topic / update_topic (message_expiry) and consumer(poll_interval=…, polling_retry_interval=…, init_retry_interval=…), plus AutoCommit.Interval(…). Those previously accepted a negative timedelta and produced a near-u64::MAX duration; they now raise ValueError.

That is the right fix, but every negative-duration case here targets the three new classes, so the pre-existing surface has no coverage of the new behavior. Worth one case on it, e.g. create_topic(..., message_expiry=timedelta(seconds=-1)) raising ValueError.

Also worth a line in the PR description: it currently says the conversion is fixed, but not that previously-accepted input now raises.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in dc180e5. The PR description now states the behavior change.

Comment thread foreign/python/src/config.rs Outdated
Comment on lines +236 to +237
/// tls_validate_certificate: Whether to validate the server certificate.
/// Defaults to validating.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Mirroring TcpClientConfigBuilder::with_tls_validate_certificate is in scope, and the connection-string path hardcoding true (core/common/src/types/configuration/tcp_config/tcp_client_config.rs:72-73) guards a different case — strings arriving from config files or user input, not a config built in code. So no objection to exposing it.

The docstring is neutral for a flag that accepts any certificate the server presents, though. One sentence on the cost would help, e.g. "Disabling this accepts any certificate the server presents, including self-signed and mismatched ones; intended for local development only."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in ab97c0b

A separate example for the new config is not needed. The getting-started
producer and consumer now build a TcpConfig with auto-login and
reconnection instead of a connection string.
The TLS and nodelay options appear as commented-out fields in the
snippet instead of prose, and the auto_login and from_connection_string
notes are dropped.
Python users see ValueError and RuntimeError rather than the PyO3
exception names, and the wrapped Rust types are an implementation
detail.
Docstrings for methods returning Awaitable[None] said they return Ok(()),
which does not exist for a Python caller. State the raised exception
instead.
IggyDuration::as_micros() truncates the count to u64, so a duration near
timedelta.max wrapped to a wrong value instead of surviving the round
trip, and the OverflowError guard below could never fire. Read the std
Duration directly to keep the u128.
The negative-duration rejection also changed methods that shipped before
this branch, such as create_topic's message_expiry, but only the new
config classes had coverage.
The tls_validate_certificate docstring was neutral for a flag that
accepts any certificate the server presents.
@ethanlin01x

Copy link
Copy Markdown
Contributor Author

Hi @slbotbm
Thanks for the review. Renamed to the Python exception names in 1e5561d, and 42ad9ab clears the remaining Rust the module docstring. The RuntimeError references will need another pass once #3696 lands and Iggy failures become IggyError

@github-actions github-actions Bot added S-waiting-on-review PR is waiting on a reviewer and removed S-waiting-on-author PR is waiting on author response labels Aug 1, 2026
@slbotbm

slbotbm commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

@ethanlin01x could you resolve the conflicts?

/author

@github-actions github-actions Bot added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Aug 1, 2026
@ethanlin01x

Copy link
Copy Markdown
Contributor Author

/ready

@github-actions github-actions Bot added S-waiting-on-review PR is waiting on a reviewer and removed S-waiting-on-author PR is waiting on author response labels Aug 1, 2026
slbotbm
slbotbm previously approved these changes Aug 1, 2026

@hubcio hubcio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

two things that don't fit on diff lines:

  • pre-existing, separate PR: the six sync methods on IggyConsumer (consumer.rs:58-92) call blocking_lock() while holding the GIL; consume_messages holds the same mutex for its whole run, so calling e.g. consumer.name() during consumption deadlocks the interpreter (or panics if called from a sync callback body). untouched by this PR, just surfaced while tracing it.
  • follow-up issue material: the zero-duration hazards below aren't python-specific. TcpClient::create is the one choke point all TcpClientConfig construction sites funnel through (only 2 of 10 go via the builder), and e.g. --tcp-heartbeat-interval none on the CLI already maps to zero today (IggyDuration::from_str treats 0/none/disabled/unlimited the same). a fence there closes every surface at once.

Comment thread foreign/python/src/config.rs Outdated
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
inner.auto_login = auto_login.inner.clone();
inner.reconnection = reconnection.inner.clone();
inner.heartbeat_interval = heartbeat_interval

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

heartbeat_interval=timedelta(0) passes validation (only negatives are rejected) and lands in the sdk heartbeat loop at core/sdk/src/clients/client.rs:269, which does sleep(heartbeat_interval.get_duration()) in an unbounded loop - zero means pinging as fast as round trips complete, for as long as the client lives. nothing downstream reads zero as "disabled". worth rejecting zero here with the same ValueError shape as negatives.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in acd01f6 — zero now raises the same ValueError as a negative value.

Comment thread foreign/python/src/config.rs Outdated
inner: RustTcpClientReconnectionConfig {
enabled: enabled.unwrap_or(defaults.enabled),
max_retries,
interval: interval

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

interval=timedelta(0) combined with the default max_retries=None (unlimited) turns the reconnect loop at core/sdk/src/tcp/tcp_client.rs:434-441 into a tight TcpStream::connect spin with one info! line per attempt while the server is down. zero with bounded retries is a legitimate fast-retry policy, so rejecting just the zero + unlimited combination is enough.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in acd01f6. Rejects only the zero + unlimited combination.

with pytest.raises(ValueError, match="negative"):
construct(negative)

def test_zero_interval_is_allowed(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this asserts the zero-interval value from the reconnect-spin problem is legal, so the test will fight the fix later. reestablish_after is the one duration where zero is genuinely meaningful (skips the cooldown - the guard at tcp_client.rs:394 is if elapsed < interval), so retargeting the test there keeps the coverage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Retargeted to reestablish_after in acd01f6, as suggested. Split into three: zero reestablish_after, zero interval with bounded retries, and zero interval with reconnection disabled — the three places zero is meaningful.

Comment thread foreign/python/src/client.rs Outdated
builder = builder.init_retries(
init_retries,
py_delta_to_iggy_duration(&init_retry_interval),
py_delta_to_iggy_duration(&init_retry_interval)?,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

init_retry_interval=timedelta(0) reaches time::interval(interval.get_duration()) at core/sdk/src/clients/consumer.rs:323, and tokio asserts period must be non-zero - a rust panic that surfaces in python as pyo3_async_runtimes.RustPanic, naming neither the argument nor the class. the interval is constructed unconditionally, so it fires every time, even when the stream and topic already exist. since this line now validates the sign, rejecting zero here too is one line.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in acd01f6 — rejected on the same line that validates the sign.

Comment thread foreign/python/src/client.rs Outdated
if let Some(polling_retry_interval) = polling_retry_interval {
builder =
builder.polling_retry_interval(py_delta_to_iggy_duration(&polling_retry_interval))
builder.polling_retry_interval(py_delta_to_iggy_duration(&polling_retry_interval)?)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

same zero hole: polling_retry_interval=timedelta(0) becomes the retry sleep in core/sdk/src/clients/consumer.rs:690-699 (while !can_poll || ... { trace!(); sleep(...) }) - no syscall in the loop body, so it burns a core, and with auto_join_consumer_group=False the join flag never flips and the spin is permanent. the field is renamed mid-chain to reconnection_retry_interval (consumer_builder.rs:247), in case you grep for it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in acd01f6.

Comment thread foreign/python/src/config.rs Outdated
#[pyclass(from_py_object)]
#[derive(Clone)]
pub struct TcpConfig {
auto_login: AutoLogin,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

auto_login and reconnection are stored twice - as wrapper fields here and inside inner (both assigned in __new__). nothing can make them diverge (no setters), but it's two copies of one truth a reader has to prove safe, and it keeps an extra live copy of the password (SecretString) per config object. both inner fields are pub, so the getters can rebuild the wrappers on demand (AutoLogin { inner: self.inner.auto_login.clone() }), which also makes impl Default for AutoLogin and the Default derive on TcpReconnectionConfig dead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in acd01f6 — the wrapper fields are gone and the getters rebuild from inner. impl Default for AutoLogin and the Default derive on TcpReconnectionConfig went with them.

Comment thread foreign/python/src/config.rs Outdated
tls_validate_certificate: Option<bool>,
#[gen_stub(override_type(type_repr = "builtins.bool | None"))] nodelay: Option<bool>,
) -> PyResult<Self> {
let defaults = RustTcpClientConfig::default();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

TcpClientConfigBuilder is #[derive(Default)] over TcpClientConfig, so the config coming out of build() already carries every default - this defaults is a second identical construction and the unwrap_or(defaults.x) tails re-apply values that are already there. assigning only on Some (or building one exhaustive struct literal sourcing untouched fields from the builder output) drops ~10 lines. two constraints if you do: keep the trimmed server_address from the builder output (build() trims it), and leave max_retries as the raw pass-through it is - None there is a real user value meaning unlimited, not "unset".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in acd01f6. The trimmed address and the raw max_retries pass-through are preserved.

Comment thread foreign/python/src/client.rs Outdated
};
let tcp_client = TcpClient::create(config)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))?;
let client = IggyClientBuilder::new()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IggyClientBuilder::new().with_client(..).build() plus the map_err guards an error that can't happen - build() only fails when no client was set. RustIggyClient::new(ClientWrapper::Tcp(tcp_client)) is public, infallible and identical (partitioner/encryptor default to None), so this collapses to one line without the dead error path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in acd01f6.


async def main():
args: ArgNamespace = parse_args()
config = build_config(args)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

build_config() can now raise ValueError (address validation moved to TcpConfig) and it sits outside the try below - --tcp-server-address 127.0.0.1 (no port) passes the argparse url check and produces a raw traceback. same in producer.py, which has no try at all.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 72025b3 — both examples catch the ValueError and print the message instead of a traceback.

Comment thread foreign/python/README.md Outdated
heartbeat_interval=timedelta(seconds=5),
# tls_enabled=True,
# tls_domain="localhost",
# tls_ca_file="core/certs/iggy_ca_cert.pem",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this path is relative to the repo root, but a reader of this readme runs from foreign/python - the same file is ../../core/certs/iggy_ca_cert.pem from a sibling dir (that's what the examples readme uses).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 71e3936.

@github-actions github-actions Bot added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Aug 4, 2026
The topic API landed on master while this branch was in review: message_expiry
and max_topic_size are now IggyExpiry and MaxTopicSize objects rather than a
timedelta and an int, and send_messages returns SendMessagesResponse. Took
those, and the negative-expiry test follows the new type.

Both sides had added their own timedelta conversions, so the two copies master
put in consumer.rs and topic.rs give way to the duration module this branch
introduced, which is where topic.rs now imports both directions from. The
message naming the topic bound is kept at the call site, since the shared
conversion has no way to know what the duration is for.
A zero duration reads as "disabled" nowhere in the client: heartbeat_interval
pings for as long as the client lives, a reconnection interval with unlimited
retries reconnects in a continuous loop, polling_retry_interval spins without a
syscall in the loop body, an AutoCommit interval spins and then floods the
server with offset stores, and init_retry_interval panics inside the runtime
timer without naming the argument that caused it. Each is now rejected where
the sign is already validated, except where zero is meaningful: the cooldown
before reestablishing, a bounded fast-retry interval, and any interval on a
reconnection policy that is switched off.

Building the configuration no longer keeps a second copy of the credentials and
the reconnection policy beside the one the transport reads, no longer rebuilds
the defaults the builder already produced, and no longer routes through a
client builder whose only failure mode cannot happen here. Converting a
timedelta now goes through the conversion pyo3 ships, keeping the message that
does not name Rust types.
The docstrings still described the surface as it behaved before durations were
validated: a negative message_expiry or consumer interval now raises ValueError
at the call rather than becoming a near-maximum duration, and a zero consumer
interval raises it too. A malformed address reaches the user as ValueError
through TcpConfig and as RuntimeError through the string form, which the
constructor documented as one error. tls_ca_file is silently ignored unless
certificate validation is on, and the default reconnection policy retries
forever, so an awaited call never returns while the server is down.

get_stream and get_topic still described their result as an Option, the one
Rust type name left behind when the docstrings were translated for Python
readers.
The repr of a configuration printed five of nine fields, dropping every one a
TLS handshake is debugged with, so a config that accepts any certificate read
exactly like a validating one. Durations printed in a form no constructor
accepts, which cost the repr its one job of being pasteable.

Both examples built their configuration outside the error handling, where an
address without a port reached the user as a traceback rather than as the
message the validation produced.
The maximum-interval test named a u64-microsecond boundary that the interval
never crosses; what it covers is the day conversion in the getter. The
equivalence test claimed both forms of configuration were equivalent while
asserting only that both clients authenticate, which is all the client exposes.
The path was written from the repository root, but the snippet around it is run
from foreign/python, where the certificate is two levels up. The examples readme
already spells it that way.
A max_retries outside the unsigned 32-bit range reached the caller as
OverflowError, raised by the argument conversion before any code here ran, so
it named neither the argument nor the range. OverflowError is not a ValueError,
so a caller guarding construction the way the getting-started examples do never
caught it. The count is now taken wide and narrowed here, where the message can
say which argument it is and what it accepts.
@ethanlin01x

Copy link
Copy Markdown
Contributor Author

@hubcio
Thanks for the thorough review — the pointers into the SDK internals taught me a lot.

On the two that didn't fit on diff lines, the blocking_lock() deadlock and the zero-duration fence at TcpClient::create: want me to open them as follow-up issues?

@ethanlin01x

Copy link
Copy Markdown
Contributor Author

/ready

@ethanlin01x
ethanlin01x requested a review from hubcio August 5, 2026 19:56
@github-actions github-actions Bot added S-waiting-on-review PR is waiting on a reviewer and removed S-waiting-on-author PR is waiting on author response labels Aug 5, 2026
@hubcio

hubcio commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

@ethanlin01x you can fix them without creation of issue, just mention that it was found in #3776.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-review PR is waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python SDK: IggyClient exposes no client configuration — no reconnection or auto-login, so session recovery is unreachable (Go/C# already expose it)

4 participants