Skip to content

Fix statsd/prometheus/exception bugs and restrict metrics bind address#103

Merged
gmr merged 3 commits into
mainfrom
fix/metrics-and-exceptions
Jul 6, 2026
Merged

Fix statsd/prometheus/exception bugs and restrict metrics bind address#103
gmr merged 3 commits into
mainfrom
fix/metrics-and-exceptions

Conversation

@gmr

@gmr gmr commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes 5 verified bugs in statsd.py, prometheus.py, models.py, and exceptions.py.

Problem / Solution

Testing

Full suite green (235 tests) + ruff clean. New tests/test_exceptions.py, tests/test_prometheus.py, and new statsd test cases. (test_prometheus.py skips if the prometheus_client extra isn't installed.)

Closes #77
Closes #79
Closes #93
Closes #94
Closes #95

Summary by CodeRabbit

  • New Features
    • Prometheus exporter can now bind to a configurable address (default 127.0.0.1).
    • StatsD configuration can now default from STATSD_HOST, STATSD_PORT, and STATSD_PREFIX, with STATSD_PORT safely falling back to 8125 when invalid.
  • Bug Fixes
    • Improved exception str()/repr() formatting fallbacks when templates/arguments don’t match.
    • Prometheus custom metrics recording is more defensive and avoids propagation of metric-time errors.
    • Improved StatsD TCP send reliability with clearer timeout handling and reconnect behavior.
  • Tests
    • Added coverage for exception formatting, Prometheus address/metrics behavior, and StatsD env fallbacks + TCP send/reconnect logic.

Closes #77: statsd TCP mode no longer shuts the consumer down over a
transient socket hiccup. The socket now uses a short blocking timeout
instead of setblocking(False), _send() uses sendall() (avoiding partial
writes), BlockingIOError/TimeoutError are logged and dropped rather than
treated as fatal, and a hard OSError on TCP now routes through
_tcp_on_closed() to reconnect (falling back to the failure callback only
if reconnect fails) instead of leaving that path dead.

Closes #93: STATSD_HOST/PORT/PREFIX env fallback now takes effect. The
env resolution moved into StatsdConfig field default_factories so the
documented precedence (explicit config > env var > hardcoded default)
holds even though process.py builds the client from model_dump(), which
always emits every field. The Client._setting() env lookup is left in
place for direct/standalone use, keeping the module docstring accurate.

Closes #94: RejectedException no longer discards value= or crashes on
kwargs-only construction. raw_value defaults to '' (was a stray format
template), __str__ formats value only when set and guards
IndexError/KeyError, and __repr__ keys off value for consistency.

Closes #95: custom Prometheus metrics are cached by their sanitized
name (matching the registered name), so keys that sanitize alike (e.g.
db.query / db-query) hit the cache instead of re-registering and raising
Duplicated timeseries. observe() also wraps its body in try/except so a
metrics error is logged rather than escaping into MCP stats collection.

Closes #79: PrometheusConfig gains an address field defaulting to
127.0.0.1, and start() passes addr=address to start_http_server. This
changes the bind default from the implicit 0.0.0.0 to loopback (the
secure default the issue requests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a203a181-95d4-4c7a-b59b-7d9570114ffa

📥 Commits

Reviewing files that changed from the base of the PR and between b4b9b5e and 11780ac.

📒 Files selected for processing (2)
  • rejected/statsd.py
  • tests/test_statsd.py
💤 Files with no reviewable changes (2)
  • tests/test_statsd.py
  • rejected/statsd.py

📝 Walkthrough

Walkthrough

This PR updates exception string behavior, adds Prometheus bind-address support and metric-key sanitization, hardens Prometheus observation error handling, and changes StatsD TCP send/reconnect behavior plus environment-driven configuration defaults. Tests were added for the new behavior.

Changes

Behavior fixes bundle

Layer / File(s) Summary
RejectedException value and formatting
rejected/exceptions.py, tests/test_exceptions.py
RejectedException now defaults value to '', formats through a guarded __str__, and uses self.value for __repr__ emptiness checks. Tests cover str(), repr(), formatting fallback, and metric capture.
Prometheus bind address
rejected/models.py, rejected/prometheus.py, rejected/mcp.py, tests/test_prometheus.py
PrometheusConfig gains address, prometheus.start() accepts and forwards it to start_http_server, and the mcp.py call site passes the new argument. Tests cover default and explicit bind addresses.
Prometheus metric caching and observe handling
rejected/prometheus.py, tests/test_prometheus.py
Custom metric caches now use sanitized metric keys, and observe() wraps metric recording in a broad try/except that logs warnings instead of raising. Tests cover cache collisions, duplicate timeseries handling, and disabled-exporter no-ops.
StatsD TCP send and reconnect
rejected/statsd.py, tests/test_statsd.py
TCP sending now uses sendall, treats blocking and timeout errors as non-fatal, reconnects on other socket errors, and applies configurable connect/socket timeouts. Tests cover TCP send behavior and reconnect paths.
StatsD environment defaults
rejected/models.py, tests/test_statsd.py
StatsdConfig now derives host, port, and prefix defaults from STATSD_* environment variables with fallback values, and invalid ports fall back to 8125. Tests cover env precedence and defaults.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MasterControlProgram
  participant prometheus.start
  participant prometheus_client

  MasterControlProgram->>prometheus.start: start(port, address)
  prometheus.start->>prometheus_client: start_http_server(port, addr=address)
Loading
sequenceDiagram
  participant statsd.Client
  participant TCP Socket
  participant failure_callback

  statsd.Client->>TCP Socket: sendall(data)
  alt BlockingIOError or TimeoutError
    TCP Socket-->>statsd.Client: raise
    statsd.Client->>statsd.Client: log warning
  else OSError
    TCP Socket-->>statsd.Client: raise
    statsd.Client->>statsd.Client: reconnect if TCP
    statsd.Client->>failure_callback: call when previously connected
  end
Loading

Possibly related PRs

  • gmr/rejected#54: Both PRs modify the StatsD TCP client path in rejected/statsd.py, including send and reconnect behavior.
  • gmr/rejected#59: Both PRs modify the Prometheus exporter flow, including PrometheusConfig.address and prometheus.start() binding behavior.
  • gmr/rejected#60: Both PRs modify rejected/exceptions.py exception value and formatting behavior.

Poem

A rabbit hopped through logs at dawn,
Fixed the strings and metrics yawn.
It bound the port, then set things right,
And sent its packets clean and bright—
🐇✨ hop, hop, all green!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main changes: statsd, Prometheus, and exception fixes plus the Prometheus bind-address restriction.
Linked Issues check ✅ Passed The PR addresses #77, #79, #93, #94, and #95 with the expected code and test changes.
Out of Scope Changes check ✅ Passed The extra changes are supporting tests and small wiring updates that stay within the linked bug fixes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@gmr

gmr commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
rejected/exceptions.py (1)

26-34: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Single positional-arg case leaves the value string in self.args, corrupting .format() output.

For len(args) == 1, self.args is always set to args regardless of whether 'value' not in kwargs, but raw_value still consumes args[0] as the template. This means the value string ends up being passed to its own .format(*self.args, ...) call as a positional argument.

For example, MessageException('bad value: {0}') (a single positional message that happens to contain a placeholder) produces 'bad value: bad value: {0}' instead of raising IndexError/being returned as-is, or 'bad value: {0}' unmodified. Compare with the len(args) > 1 branch, which correctly strips args[0] via args[1:] when 'value' not in kwargs — the same stripping should apply when len(args) == 1.

None of the added tests exercise this path (test_plain_message uses a template with no placeholders, so the bug is masked).

🐛 Proposed fix
     def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
-        if len(args) > 1:
-            self.args = args[1:] if 'value' not in kwargs else args
-        else:
-            self.args = args
+        if args and 'value' not in kwargs:
+            self.args = args[1:]
+        else:
+            self.args = args
         self.metric: str | None = kwargs.pop('metric', None)
         raw_value = kwargs.pop('value', args[0] if args else '')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rejected/exceptions.py` around lines 26 - 34, The single positional-arg path
in the exception initializer leaves the template string in `self.args`, which
then gets fed back into `.format()` and corrupts output. Update
`MessageException.__init__` in `rejected/exceptions.py` so the `len(args) == 1`
case handles `self.args` the same way as the `len(args) > 1` branch by stripping
the message/template argument when `value` is not provided. Keep `raw_value` as
the template source, but ensure the stored formatting args only contain real
substitution values, and add coverage for a single-argument message containing
placeholders.
tests/test_exceptions.py (1)

1-49: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add a case for single positional arg + .format() placeholder to catch the self.args bug in __init__.

None of these tests cover MessageException('template with {0}') (single positional arg containing a format placeholder, no value= kwarg, no extra args) — this is the exact scenario affected by the self.args slicing bug flagged in rejected/exceptions.py. Adding it here would have caught the regression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_exceptions.py` around lines 1 - 49, Add a regression test in
RejectedExceptionTestCase for MessageException with a single positional format
string containing a placeholder and no extra args or value= kwarg. The current
tests miss the exact __init__ path in rejected.exceptions.MessageException where
self.args slicing breaks formatting for this case. Verify str() returns the
formatted message for MessageException('template with {0}') so the bug is caught
if it regresses again.
🧹 Nitpick comments (2)
rejected/prometheus.py (2)

128-128: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log message omits the bind address.

The startup log only reports the port, not the address it's binding to, which is the actual security-relevant detail this PR is introducing (issue #79).

♻️ Proposed fix
-    LOGGER.info('Prometheus metrics server started on port %d', port)
+    LOGGER.info(
+        'Prometheus metrics server started on %s:%d', address, port
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rejected/prometheus.py` at line 128, The Prometheus startup log in
prometheus.py only reports the port and omits the bind address, which is the key
detail introduced by this change. Update the LOGGER.info call in the Prometheus
server startup path to include the actual bind address alongside the port, using
the relevant startup variables in the same block so the message clearly states
where the server is listening.

224-247: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Single try/except wraps unrelated metrics; one failure silently drops all others.

Wrapping the entire body in one try means a failure in an early operation (e.g. duration_hist.observe) prevents age_hist, all custom_durations, custom_counters, and custom_gauges from being recorded that call — not just the failing metric. This creates silent, broader-than-necessary data loss beyond what's needed to prevent a duplicate-timeseries error from propagating.

Consider scoping the try/except per-metric-group (or at least per-key inside the loops) so an error in one custom metric doesn't suppress unrelated observations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rejected/prometheus.py` around lines 224 - 247, The current Prometheus
observation flow in the metrics recording function uses one broad try/except
around all metric updates, so a failure in one call can prevent unrelated
metrics from being recorded. Refactor the logic in the observation routine to
scope exception handling more narrowly around each metric group, such as the
duration histogram, message_age histogram, and each custom metric loop in
separate try/except blocks, so one bad metric does not suppress the others while
still handling duplicate-timeseries errors gracefully.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rejected/models.py`:
- Around line 53-55: The STATSD_PORT parsing in the config field initializer is
brittle because invalid env values can crash Config.model_validate. Update the
port field in rejected/models.py by guarding the os.environ lookup used in the
default_factory so it falls back to 8125 when parsing fails, and keep the fix
localized to the same field definition that currently calls
int(os.environ.get(...)).

In `@rejected/statsd.py`:
- Around line 164-166: The TCP timeout is being reused for both connection
establishment and socket writes in the StatsD client, which can make slow
remotes fail too early during connect. Update the connection path in the statsd
socket handling logic so the connect attempt uses a longer bounded timeout, then
switch to the short TCP timeout only after a successful connection for
send/write operations; use the relevant StatsD send/connect method and the
sock.settimeout flow to locate the change.
- Around line 118-126: The send path in statsd.py currently treats TimeoutError
from _tcp_writer.sendall() the same as a dropped metric, but a timeout after
partial writes leaves the TCP stream unusable for the next metric. Update the
write logic in the send method to distinguish TimeoutError from BlockingIOError,
and when sendall() times out call _tcp_on_closed() so the TCP writer is reset
before the next write. Keep the existing LOGGER.warning behavior for the
non-fatal case and reference the _tcp_writer, _tcp_on_closed(), and sendall()
paths when making the change.

In `@tests/test_prometheus.py`:
- Around line 122-127: The explicit-address test in test_explicit_address still
contains the same S104 literal-address issue, so add the matching Ruff
suppression in this test to make the assertion and mocked start_http_server call
pass a clean lint run. Apply the same targeted nosec/ignore-style suppression
used elsewhere in the test suite around the prometheus.start invocation and the
addr assertion so both 0.0.0.0 occurrences are covered without changing the test
behavior.
- Around line 21-23: The test in test_address_configurable is tripping Ruff S104
because it hardcodes the literal “0.0.0.0” in a string assertion. Update the
test to avoid embedding that exact literal directly—use a shared test constant
or construct the expected address dynamically—and keep the round-trip check
against PrometheusConfig so the behavior is still verified without triggering
the static analysis rule.

---

Outside diff comments:
In `@rejected/exceptions.py`:
- Around line 26-34: The single positional-arg path in the exception initializer
leaves the template string in `self.args`, which then gets fed back into
`.format()` and corrupts output. Update `MessageException.__init__` in
`rejected/exceptions.py` so the `len(args) == 1` case handles `self.args` the
same way as the `len(args) > 1` branch by stripping the message/template
argument when `value` is not provided. Keep `raw_value` as the template source,
but ensure the stored formatting args only contain real substitution values, and
add coverage for a single-argument message containing placeholders.

In `@tests/test_exceptions.py`:
- Around line 1-49: Add a regression test in RejectedExceptionTestCase for
MessageException with a single positional format string containing a placeholder
and no extra args or value= kwarg. The current tests miss the exact __init__
path in rejected.exceptions.MessageException where self.args slicing breaks
formatting for this case. Verify str() returns the formatted message for
MessageException('template with {0}') so the bug is caught if it regresses
again.

---

Nitpick comments:
In `@rejected/prometheus.py`:
- Line 128: The Prometheus startup log in prometheus.py only reports the port
and omits the bind address, which is the key detail introduced by this change.
Update the LOGGER.info call in the Prometheus server startup path to include the
actual bind address alongside the port, using the relevant startup variables in
the same block so the message clearly states where the server is listening.
- Around line 224-247: The current Prometheus observation flow in the metrics
recording function uses one broad try/except around all metric updates, so a
failure in one call can prevent unrelated metrics from being recorded. Refactor
the logic in the observation routine to scope exception handling more narrowly
around each metric group, such as the duration histogram, message_age histogram,
and each custom metric loop in separate try/except blocks, so one bad metric
does not suppress the others while still handling duplicate-timeseries errors
gracefully.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 04e46672-2d47-4742-8dc8-7b233ebead01

📥 Commits

Reviewing files that changed from the base of the PR and between 401dd78 and 8b64451.

📒 Files selected for processing (8)
  • rejected/exceptions.py
  • rejected/mcp.py
  • rejected/models.py
  • rejected/prometheus.py
  • rejected/statsd.py
  • tests/test_exceptions.py
  • tests/test_prometheus.py
  • tests/test_statsd.py

Comment thread rejected/models.py Outdated
Comment thread rejected/statsd.py Outdated
Comment thread rejected/statsd.py Outdated
Comment thread tests/test_prometheus.py
Comment thread tests/test_prometheus.py
- exceptions: fix RejectedException.__init__ so a lone positional
  template is not fed back into its own .format() call; strip the
  message arg whenever no value= kwarg is given (covers len(args) == 1)
- statsd: reconnect on TimeoutError from sendall() since a partial
  write leaves the TCP stream unusable for the next metric
- statsd: use a longer bounded connect timeout, applying the short
  TCP_TIMEOUT only after the connection is established
- models: guard STATSD_PORT parsing so a non-numeric env var falls
  back to 8125 instead of aborting Config.model_validate
- prometheus: include the bind address in the startup log message
- tests: add regression coverage for the above

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gmr

gmr commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Addressed the CodeRabbit review in b4b9b5e. Notes on the out-of-diff findings and nitpicks:

Fixed

  • exceptions.py — single positional-arg self.args bug (Major): confirmed. MessageException('bad value: {0}') fed the template back into its own .format() call. Switched the guard to if args and 'value' not in kwargs: self.args = args[1:] so the lone template is stripped just like the multi-arg branch; a placeholder with no substitution arg now returns verbatim. Added the requested regression test (test_single_positional_with_placeholder).
  • prometheus.py:128 — log omits bind address (nitpick): done, now logs %s:%d with the address.

Declined

  • prometheus.py observe() per-metric try/except (nitpick): keeping the single wrapper. Its purpose is to stop any collector error (e.g. a Duplicated timeseries ValueError) from propagating into MCP stats collection; splitting into ~5 try/except blocks adds surface for marginal isolation benefit and cuts against this repo's simplicity-first convention. The realistic failure (duplicate registration) originates at metric creation and would affect the whole call regardless.
  • The two test_prometheus.py S104 findings: false positives for this repo — per-file-ignores disables the whole S ruleset in tests/**, so ruff check is clean. Replied inline on both.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
rejected/statsd.py (1)

118-133: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

BlockingIOError branch looks unreachable given current socket config.

Both sockets in this class are now configured so this branch should never fire: the TCP socket uses settimeout(TCP_CONNECT_TIMEOUT) then settimeout(TCP_TIMEOUT) (both positive floats), and the UDP socket sets no timeout at all (fully blocking). Per Python socket semantics, BlockingIOError is only raised when a socket's timeout is exactly 0 (non-blocking mode via setblocking(False)/settimeout(0)); a positive timeout raises socket.timeout/TimeoutError instead, which is already handled by the block right below it (lines 128-133).

The previous setblocking(False) call for the TCP socket was removed as part of this diff (per the summarized diff), so there's no remaining code path that could trigger BlockingIOError here — it's effectively dead defensive code that may mislead future maintainers into thinking backpressure is split across two branches when only the TimeoutError path is actually exercised.

Worth confirming intent: if this is purely defensive/future-proofing (e.g., anticipating a future non-blocking mode), consider a short comment noting that; otherwise it can be removed to avoid confusion in this hot networking path.

🧹 Possible cleanup if confirmed unreachable
-        except BlockingIOError as error:
-            LOGGER.warning(
-                'Dropping statsd metric, socket not ready: %s', error
-            )
-        except TimeoutError as error:
+        except TimeoutError as error:
             LOGGER.warning('Timeout sending statsd metric: %s', error)
             if self._tcp_writer:
                 # sendall may have written a partial metric, leaving the
                 # stream unusable; reconnect before the next write.
                 self._tcp_on_closed()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rejected/statsd.py` around lines 118 - 133, The BlockingIOError handling in
statsd.py appears unreachable with the current socket setup in the Statsd
client, since _tcp_writer uses positive timeouts and _udp_sock is blocking, so
only TimeoutError should occur. Review the send path in the Statsd class
(including the sendall/sendto try block and the _tcp_on_closed reconnect logic)
and either remove the dead BlockingIOError branch or add a brief comment if you
intend to keep it for future non-blocking mode support.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rejected/statsd.py`:
- Around line 118-133: The BlockingIOError handling in statsd.py appears
unreachable with the current socket setup in the Statsd client, since
_tcp_writer uses positive timeouts and _udp_sock is blocking, so only
TimeoutError should occur. Review the send path in the Statsd class (including
the sendall/sendto try block and the _tcp_on_closed reconnect logic) and either
remove the dead BlockingIOError branch or add a brief comment if you intend to
keep it for future non-blocking mode support.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 85b22d97-f830-4fd3-9510-445dc86074b3

📥 Commits

Reviewing files that changed from the base of the PR and between 8b64451 and b4b9b5e.

📒 Files selected for processing (6)
  • rejected/exceptions.py
  • rejected/models.py
  • rejected/prometheus.py
  • rejected/statsd.py
  • tests/test_exceptions.py
  • tests/test_statsd.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • rejected/models.py
  • tests/test_exceptions.py
  • rejected/prometheus.py
  • tests/test_statsd.py
  • rejected/exceptions.py

Both sockets are configured with blocking semantics: the TCP socket
uses positive settimeout() values and the UDP socket has no timeout,
so sendall()/sendto() raise TimeoutError rather than BlockingIOError
(the latter only fires on non-blocking sockets). The branch was dead
code left over from the setblocking(False) approach this PR replaced.
Dropped it and the test that simulated the impossible case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gmr

gmr commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Addressed the remaining nitpick from the latest review (rejected/statsd.py:118-133 — "BlockingIOError branch looks unreachable") in 11780ac.

Confirmed unreachable: the TCP socket uses positive settimeout() values (TCP_CONNECT_TIMEOUT then TCP_TIMEOUT) and the UDP socket sets no timeout (fully blocking), so sendall()/sendto() raise TimeoutError, never BlockingIOError — that exception only fires on non-blocking sockets (settimeout(0)/setblocking(False)), and this PR removed the old setblocking(False) call. Removed the dead branch and the test_blocking_io_error_is_non_fatal test that simulated the impossible case. Full suite (236 tests) and ruff are green.

@gmr gmr merged commit a6bf282 into main Jul 6, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment