Skip to main content

flakynet

Seeded, reproducible network faults for testing real clients over real sockets. The core library needs no Docker, no sidecar, and no event loop of yours.

Status: alpha. The seven faults, seeded resolution, events, fault reports, and the pytest fixture are implemented and covered by tests. The public API may still change before 1.0; platform and determinism limits are documented in docs/faults.md and docs/determinism.md.

Why

Clients usually fail in production in ways unit tests never exercise: the server resets mid-response, a write arrives in three-byte pieces, a commit is delivered but its acknowledgement never comes back. Testing this today means running a proxy daemon in Docker, or hand-writing socket mocks that do not behave like a network.

flakynet runs a small TCP proxy inside your test process. You point a TCP client at it, describe the faults you want, and give it a seed. When a test fails, the seed replays the same fault plan.

Quick look

This example is executed by tests/test_readme.py; it starts a real local server, connects through flakynet, and checks the fault report.

import socket
import threading

import flakynet as fn


def serve_once(listener: socket.socket) -> None:
    connection, _ = listener.accept()
    with connection:
        connection.sendall(b"x" * 64)
        connection.shutdown(socket.SHUT_WR)


listener = socket.create_server(("127.0.0.1", 0))
server = threading.Thread(target=serve_once, args=(listener,), daemon=True)
server.start()

plan = fn.Plan(
    fn.Fragment(max_bytes=3, side="server"),
    fn.Reset(after_bytes=40, side="server"),
)

with fn.proxy(listener.getsockname(), plan, seed=7) as p:
    with socket.create_connection((p.host, p.port), timeout=5.0) as client:
        received = bytearray()
        while True:
            try:
                chunk = client.recv(1024)
            except OSError:
                break
            if not chunk:
                break
            received.extend(chunk)
    p.assert_all_fired()
    print(f"received {len(received)} bytes")
    print([(event.fault, event.kind, event.offset) for event in p.canonical_events()])

server.join(timeout=5.0)
listener.close()

Plans. Plan(*faults) is the canonical form. proxy() also accepts a single fault or any iterable of faults and normalizes them, so fn.proxy(target, fn.Reset(...)) is shorthand for fn.proxy(target, fn.Plan(fn.Reset(...))). An empty plan is a pass-through proxy.

Sides. side names the stream a fault affects: "client" is what the client sends, "server" is what the server sends. saw(..., side=...) names the stream it watches.

Seeds. seed defaults to 0, so phase-1 plans are deterministic unless you ask for variety (explicit seeds, or --flakynet-sweep). Runtime event occurrence still depends on scheduling; see docs/determinism.md.

With pytest

This block is a complete test module; tests/test_readme.py runs it under pytest.

import socket
import socketserver
import threading

import flakynet as fn


class BytesServer(socketserver.ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True


class BytesHandler(socketserver.BaseRequestHandler):
    def handle(self) -> None:
        self.request.sendall(b"x" * 64)


def test_reset_is_replayed(flakynet):
    server = BytesServer(("127.0.0.1", 0), BytesHandler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        p = flakynet.proxy(
            server.server_address,
            fn.Reset(after_bytes=32, side="server"),
            seed=7,
        )
        with socket.create_connection((p.host, p.port), timeout=5.0) as client:
            while True:
                try:
                    if not client.recv(1024):
                        break
                except OSError:
                    break
        p.assert_all_fired()
        assert p.bytes_server_to_client == 32
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=2.0)

flakynet.proxy(...) returns an already-started proxy and stops it when the test ends; you can create several per test. Unnamed proxies are called p0, p1, ... in creation order. The seed is chosen in this order: an explicit seed= in the test (pinned), then --flakynet-seed, then the current --flakynet-sweep value, then 0. When a test using a proxy fails, the report prints each proxy's name, effective seed, whether it was pinned, repr(plan) and the exact replay command.

pytest --flakynet-seed=7        # replay one failing run
pytest --flakynet-sweep=200     # try seeds 0..199 serially; stop at the first failing seed

With pytest-xdist, each worker keeps its own sweep state. Run the sweep in a single process when you need the global stop-at-first-failure behaviour and the controller's first-failing-seed summary.

Integration tests

The integration suite runs real redis-py, psycopg, httpx, and aiohttp clients against real services. It is marked integration; the normal pytest run skips it when the optional clients or services are absent.

Start Redis and PostgreSQL with the bundled compose file:

docker compose up -d

Install the optional clients and run the suite:

uv sync --group integration
uv run pytest -m integration

The Redis and PostgreSQL URLs default to redis://127.0.0.1:6379/0 and postgresql://postgres:postgres@127.0.0.1:5432/postgres. Override them with FLAKYNET_REDIS_URL and FLAKYNET_POSTGRES_DSN when your services live elsewhere; the PostgreSQL DSN must use a TCP host and port. The httpx and aiohttp tests start their own local HTTP server; only Redis and PostgreSQL need external processes. CI runs this suite in a separate job with Redis and PostgreSQL service containers.

Observed client behaviour and review candidates are recorded in docs/bug-zoo.md.

Faults

Fault Kind What it does at the socket level
Latency window Delays each forwarded segment by a planned amount plus jitter
Bandwidth window Paces segments at a planned bytes-per-second rate
Fragment window Forwards data in pieces of at most N bytes
CloseAfter discrete Shutdown/close after N bytes; normally FIN, can be RST if unread data remains
Reset discrete SO_LINGER(1, 0) then close after N bytes; RST on Linux, expected on macOS, not guaranteed on Windows
Blackhole discrete Drains and discards after the trigger, leaving the connection open
RefuseConnect discrete Accepts then immediately aborts selected new connections

See docs/faults.md for the exact trigger rules and the per-OS FIN/RST table, including the current Windows SO_LINGER limitation.

There is deliberately no "packet loss" fault. TCP hides packet loss from applications; what a client actually sees is stalls, resets, truncation and fragmentation, which is what flakynet models.

Latency applies per forwarded segment. A segment is each piece the pump writes after splitting at fault boundaries, so every Fragment piece is its own segment. The delay for segment n is planned exactly and replays, but which bytes receive which delay is not replayable: the OS decides how reads are chunked, and the chunking decides where segments end. Latency logs only window_entered and window_exited, never per-segment events, so its event vocabulary does not expose which bytes received which delay. Measured elapsed time is not promised.

Bandwidth draws its rate once per connection and stream at accept time. The rate is part of the resolved plan; actual measured throughput is not promised.

Blackhole drains and discards the affected stream after the trigger rather than pausing reads. The sender's writes keep succeeding, the connection stays open, and the peer sees only the bytes before the trigger. It does not model TCP backpressure on the sender.

Determinism

The seed pins phase-1 resolution, not the runtime schedule. For each connection, (seed, proxy name, connection index) deterministically chooses which faults apply, their static offsets/windows, bandwidth rates, armed deltas, and the latency jitter key. No runtime code draws from a shared RNG, and the process-wide random state is irrelevant.

Deterministic offsets need a deterministic byte stream. For one connection with identical bytes in each direction and identical close boundaries, static faults and same-stream saw anchors produce identical offsets, and their own events are identical when the stream reaches the same end. The matcher is chunk-invariant and the pump splits at fault boundaries, so OS read sizes do not move a static offset or a same-stream anchor.

What is not deterministic: arrival order and timestamps; concurrent connection indices; cross-stream saw offsets; competing terminals and sibling cancellation; chunk-dependent Latency/Bandwidth pacing; measured time; application and OS FIN/RST behaviour; and report statuses that depend on those races. canonical_events() removes order and inexact offsets, but it cannot make a scheduling-dependent event happen.

proxy.events is in arrival order and is diagnostic only. proxy.canonical_events() sorts by (connection, stream, offset, fault_index, kind) and removes the sequence number and timestamp; a non-exact offset becomes None. Window faults log window_entered and window_exited; discrete faults log fired; Latency never logs per-segment events. When comparing concurrent connections, compare the multiset of per-connection event sequences, not the mapping to clients.

See docs/determinism.md for the exact resolution draw order, the full limits, report-status caveats, and comparison recipes. tests/test_determinism_limits.py pins these limits.

Fault reports

This block is also executed by tests/test_readme.py. It uses RefuseConnect so it needs no target server: the proxy refuses the connection before connecting upstream.

import socket

import flakynet as fn

with fn.proxy(("127.0.0.1", 1), fn.RefuseConnect(), seed=7) as p:
    client = socket.create_connection((p.host, p.port), timeout=5.0)
    try:
        while True:
            try:
                if not client.recv(1):
                    break
            except OSError:
                break
    finally:
        client.close()
    for status in p.fault_report():
        print(status.describe())
    p.assert_all_fired()
    p.assert_all_fired(allow={"unmatched"})
Status Meaning
FIRED The fault fired (window faults: first byte forwarded inside the window)
PREEMPTED An earlier flakynet fault ended the connection first
ENDED_EARLY A client or server aborted the connection before the trigger point
MISSED The exchange ended normally and the trigger was never reached (offset beyond the stream, or pattern absent)
PENDING The connection was still open when the report was taken
UNMATCHED No accepted connection matched the fault's selector

Each report entry also records how the connection ended (graceful, client_abort, server_abort, error, flakynet, or pending) and the bytes forwarded in each direction.

By default assert_all_fired() accepts PREEMPTED and ENDED_EARLY and fails on MISSED, PENDING and UNMATCHED, because a fault that silently never applied usually means the test is not testing what you think. Opt out per assertion with allow=. Call it after the client has finished.

How it compares

Tool Approach Notes
Toxiproxy Separate proxy daemon, usually a container Mature and language-agnostic; needs infrastructure
netfault Small Python TCP proxy with a fault pipeline Closest in spirit
simloop / simloom Simulated asyncio event loop with virtual time Deterministic, but asyncio code only
MockServer (chaos mode) Java server with TCP/HTTP/gRPC faults Heavier, JVM-based

flakynet's niche: real sockets, in-process, seeded replay, usable from sync, threaded and async code alike.

Non-goals

TLS termination, UDP, payload corruption, HTTP-aware faults, a CLI or daemon. Record and replay of streams may arrive later as a separate module.

Roadmap

  • v0.1 core proxy, seven faults, events and fault reports, pytest fixture, seed replay — implemented
  • v0.2 recipes and tests against redis-py, psycopg, httpx and an asyncio client — implemented; plan minimization is not implemented
  • v0.3 a "bug zoo" of real failures found in popular clients — docs/bug-zoo.md collects candidates for review

Development

uv sync
uv run pytest
uv run pytest -m integration      # after starting the services above
uv run ruff check . && uv run ruff format --check . && uv run ty check

See AGENT.md for design rules and contributor guidance.

License

TBD (MIT suggested).

Metadata

Release files for flakynet 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for flakynet 0.1.0
File Size Uploaded Provenance Actions
flakynet-0.1.0.tar.gz 197.9 kB With provenance Details for flakynet-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for flakynet 0.1.0
File Interpreter ABI Platform Actions
flakynet-0.1.0-py3-none-any.whl Python 3 none any Details for flakynet-0.1.0-py3-none-any.whl

Total release size: 226.7 kB

Release files / flakynet-0.1.0.tar.gz

Download URL flakynet-0.1.0.tar.gz
Size 197.9 kB
Tags Source
SHA-256 checksum
How to use checksums
d3a96209e4f17cfca9535e70e7f3d640628269096a679426d0e3669d4fdfe7e8
BLAKE2b-256 checksum
How to use checksums
1b01f066ebd62c88ed4d0cb5fb9c4e19cff42677ab0207b7c46a86df4407f3f2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.

Transparency log

Release files / flakynet-0.1.0-py3-none-any.whl

Download URL flakynet-0.1.0-py3-none-any.whl
Size 28.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
62e63d16c921090b53f54a8fa2f1d8864196447c1b2a13342ac651a434e75b3a
BLAKE2b-256 checksum
How to use checksums
225868bb5e64e16e2cbc0b55227ad32a90022386e0bb686987a0609cdf5528c0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.

Transparency log

Security for flakynet 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for flakynet-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 11, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page