Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,10 @@ reviews:
instructions: >-
PASS if the PR is a release preparation (title matches
`chore: prepare the * release`), or touches only .github/, benchmarks/,
planning/, test files, or developer tooling configuration that never
reaches the published package (dot-files in the repository root such as
.coderabbit.yaml, .pre-commit-config.yaml, .gitignore).
planning/, CONTRIBUTING.md, test files, or developer tooling
configuration that never reaches the published package (dot-files in
the repository root such as .coderabbit.yaml, .pre-commit-config.yaml,
.gitignore).
Otherwise FAIL unless CHANGELOG.md gains at
least one bullet under the `## [Unreleased]` heading describing the
user-visible effect of the change.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ deterministic suite only — the hypothesis suites are excluded, because a test
that reaches a branch only sometimes makes both mutmut's test-selection mapping
and the score itself irreproducible.

Baseline: **526 of 549 mutants killed (95.8%)**. Every survivor is an equivalent
Baseline: **614 of 637 mutants killed (96.4%)**. Every survivor is an equivalent
mutant, in one of five classes:

| Class | Why it cannot be killed |
Expand Down
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -245,16 +245,20 @@ do_not_mutate_patterns = ["cast\\("]
# mutmut runs pytest in-process and copies pyproject.toml into `mutants/`, so
# the `-n auto` default would fork the collector away from it.
pytest_add_cli_args = ["-n", "0", "-p", "no:cacheprovider"]
# Excluded from the mutation suite, for three different reasons:
# Excluded from the mutation suite, for four different reasons:
# - the wall-clock example runner and the extras integration tests cost
# minutes and exercise third-party code, not the two modules under mutation;
# - the hypothesis suites are randomised, and mutmut maps tests to functions
# from a single stats run — a test that reaches a branch only sometimes
# makes both that mapping and the score irreproducible. The score is a
# tracked number, so it is measured against the deterministic suite: a
# survivor then means a missing example-based test, every time.
# survivor then means a missing example-based test, every time;
# - the README link checks read repository files that mutmut does not copy
# into `mutants/`, so collection fails there; they import no library code
# and could not kill a mutant anyway.
pytest_add_cli_args_test_selection = [
"tests",
"--ignore=tests/test_readme.py",
"--ignore=tests/test_examples.py",
"--ignore=tests/test_aiohttp.py",
"--ignore=tests/test_fastapi.py",
Expand Down
14 changes: 13 additions & 1 deletion tests/test_coordination.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import asyncio
import gc
import re
import threading
import weakref
from dataclasses import replace
Expand Down Expand Up @@ -1496,8 +1497,19 @@ def test__breaker__backoff_with_shared_storage__rejected(fake_clock: FakeClock)
failed-round count crosses the wire, so a multiplier set here would be read,
validated and then quietly ignored — the exact trap the option exists to
remove.

The whole message is asserted because it is the whole remedy: a caller who
hits this has to learn which two ways out exist, and half a sentence would
leave them guessing.
"""
with pytest.raises(ValueError, match='wait_duration_backoff_multiplier'):
refusal = (
'wait_duration_backoff_multiplier has no effect on a breaker with shared '
'storage: reopening is decided by the backend from wait_duration_in_open, '
'and no failed-round count is shared. Leave it at 1.0, or drop the storage '
'to run this breaker locally. Got 2.0.'
)

with pytest.raises(ValueError, match=rf'\A{re.escape(refusal)}\Z'):
CircuitBreaker(
name='payments',
config=Config(wait_duration_backoff_multiplier=2.0),
Expand Down
26 changes: 25 additions & 1 deletion tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,30 @@ def test__half_open__every_probe_unreachable__reopens(
assert engine.state is State.OPEN


def test__half_open__ordinary_exception__stays_a_probe_verdict(
config: Config, fake_clock: FakeClock
) -> None:
"""Configuring unreachable exceptions must not turn every failure into one.

An inconclusive probe hands its slot back and ends no round, so a breaker
that mistook an ordinary failure for one would sit in HALF_OPEN while the
dependency kept failing.
"""

def boom() -> None:
raise ValueError('boom')

engine = _unreachable_engine(config, fake_clock)
_trip_to_open(engine)
fake_clock.advance(5.0)

with pytest.raises(ValueError, match='boom'):
engine.call_sync(boom)
engine.call_sync(lambda: 'ok')

assert engine.state is State.OPEN


def test__unreachable_not_configured__probe_reopens_as_before(
config: Config, fake_clock: FakeClock
) -> None:
Expand Down Expand Up @@ -539,7 +563,7 @@ def test__unreachable_exceptions__not_a_tuple__rejected_at_construction(
config: Config, fake_clock: FakeClock
) -> None:
"""``isinstance`` refuses a list of types, so refuse it here with a clear message."""
with pytest.raises(TypeError, match='must be a tuple'):
with pytest.raises(TypeError, match=r'\Aunreachable_exceptions must be a tuple, got: list\Z'):
Engine(
name='test',
config=config,
Expand Down
4 changes: 4 additions & 0 deletions tests/test_state_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,10 @@ def test__half_open__probe_round_passes__backoff_resets(
_trip_to_open(machine, 2)
assert machine.retry_after() == pytest.approx(5.0)

# Counting again from one, not resuming from three: 5 * 2, never 5 * 2**3.
_fail_probe_round(machine, fake_clock, wait=5.0)
assert machine.retry_after() == pytest.approx(10.0)


def test__open__grown_wait__probe_not_admitted_before_it_elapses(
config: Config, fake_clock: FakeClock
Expand Down
Loading