Finds where contracts are broken
Assuming your SLA breaks before your box does.
break is an HTTP benchmark that raises concurrent load until observed failures
exceed your SLA budget. You define the successful-response ratio and timeout;
brk drives the requested load and evaluates the actual HTTP outcomes in each
phase, in the environment where you run it.
A successful request is a complete 2xx response within budget. HTTP errors, transport failures and timeouts count against that phase. The result shows the last load that met your SLA and the first that did not. If the requested load cannot be established, it returns an explicit error instead of claiming a server limit. Request rate and resource measurements describe the run; request outcomes decide whether it passes.
Package: break; command and Rust library: brk.
cargo install breakbrk http://127.0.0.1:8080/api/json \
--conn 64 --start 2 --pace 2 \
--phase 10s --timeout 16ms --accept 99.99Example output:
Done (128 concurrent requests)
Done (256 concurrent requests)
Broken (384 concurrent requests)
Exit codes: 0 Done, 1 Broken, 2 argument parsing errors, 130 Ctrl-C.
--conn: fixed connection-owner count; closed connections are replaced.--start: initial concurrent requests per connection; defaults topace.--pace: additional concurrent requests per connection in each phase.--phase: time at a load level. There is no delay between requests.--timeout: request budget through the complete response, including TCP setup for the first request on a new connection. Addresses are resolved before load.--accept, alias--sla: minimum successful 2xx percentage, default 99.99.--max: optional per-connection ceiling. Without it there is no fixed final phase. Reaching it reports tested load, not a server breakpoint.--expect-body: additionally require an exact response body.--body-file: POST a file with every request;-reads standard input.--expect-echo: require the response body to match the bytes sent with--body-file.--baseline: measure machine CPU for one--phasewith no benchmark traffic before starting the load (Linux).--threads: client worker threads. Optional; recommended when client and server run on the same host. Not part of the load; see What--threadsis.--connect: connect to this address instead of resolving the URL's host. The URL still supplies theHostheader and, forhttps://, the server name.--cacert: forhttps://, trust the authorities in this PEM file instead of the platform's.--insecure: forhttps://, skip server certificate verification. It cannot be combined with--cacert; encryption remains enabled.
Phase zero targets conn × start requests. Phase n targets
conn × (start + n × pace). A completed request frees one slot and immediately
allows its replacement. Requests/s is measured, not configured. Counters reset
per phase; pending requests retain their original phase and deadline during drain.
There is no warmup or scenario-file loader.
The load is what you declare with --conn, --start and --pace: how many
requests, or connections, are outstanding at once. That is what the server is
asked to carry and what the SLA is judged on.
--threads is not part of it. The same connections carry the same concurrency
whether two threads drive them or forty. It is not a criterion of the
measurement and not something to tune for a better number: it only bounds how
much of the machine the client may use while it drives the declared load. It
exists so that a run is possible at all where client and server share a
machine.
It is optional; specifying it is recommended when client and server run on the same host. The default depends on the machine the client happens to run on, so a run that leaves it out is not the same run elsewhere; a declared count is a stated condition that another run can repeat.
- Without it there is one thread for every CPU the process may run on (following CPU affinity and cgroup quota where the platform has them), never more than there are connections. That suits a client with a machine of its own.
- On the server's own machine that default puts a full set of client threads beside the server's. The machine is then crowded, the client's own deadlines are noticed late, and the box gives way before the SLA does. Declare fewer.
- Too few and the client becomes the side that gives way: it cannot keep the declared concurrency outstanding.
The output says which of these a run was. target-coverage near 100% means the
client kept the declared load outstanding. max-deadline-lateness small beside
--timeout means the client observed its deadlines on time. While both hold,
whether a level is accepted does not depend on the thread count, which is the
sign that the count was a condition of the run and not a part of the
measurement. Report it that way, beside the machine, never as a load parameter.
The rate at that level can still be the client's; see
Known limits.
A thread count reserves no CPU and pins nothing. On a shared machine the boundary found belongs to client and server together: use it to compare servers under the same conditions, not as one server's capacity. The run prints how many worker threads it started.
The adapter sends GET requests by default, or POST requests with --body-file,
with actual HTTP/1.1 pipelining on each connection.
Requests admitted together are written together: one write carries as many as
the socket takes, and each keeps its own admission time and deadline.
It reads ordered responses while filling free request slots, reuses connections
across phases, and validates complete bodies without text decoding.
Content-Length, chunked and close-delimited bodies are supported. Unsupported or
ambiguous framing is an explicit error. Pipelined outstanding requests are a
client-side measure, not a claim that all server handlers execute simultaneously.
The URL's scheme selects the transport: http:// is TCP, https:// is TLS on the
same connection model, with the same pipelining and the same outcomes. It uses
HTTP/1.1 by default; --http2 selects HTTP/2. The handshake belongs to
connection setup: it counts against the first request's deadline. Certificate
verification is enabled by default; a verification failure is a setup failure.
Use --cacert for a private authority, or --insecure to explicitly disable
server identity verification. A connection that ends without a TLS close is a
transport failure, not a complete body. The
tls feature is enabled by default; a build without it accepts http:// only.
brk https://example.test/plain --connect 127.0.0.1:8443 --cacert authority.pem \
--conn 64 --start 2 --pace 2 --phase 10s --timeout 16ms--body-file reads the file once before load starts. Every request sends those
bytes as a POST with Content-Length and Content-Type: application/octet-stream.
--expect-echo compares the complete response body with the sent buffer, without
text decoding. Both work over HTTP/1.1 and HTTP/2.
brk http://127.0.0.1:8080/echo --body-file payload.bin --expect-echo \
--conn 64 --start 2 --pace 2 --phase 10s --timeout 100ms --accept 99.99brk has two tests. Each raises one thing and judges one contract, and they
are run separately.
| Test | Stays fixed | Raised each phase | Decides a phase |
|---|---|---|---|
brk requests (req, the default) |
connections, kept open | concurrent requests per connection | request outcomes |
brk connections (conn) |
requests per connection | concurrent connections | connection outcomes, then request outcomes |
Everything above describes the request test: connections are opened once, and a setup that fails there is a setup error that claims nothing about capacity.
In the connection test, establishing a connection is the measured work:
brk conn https://example.test/plain --start 64 --pace 64 --requests 1 \
--phase 10s --timeout 100ms--start,--pace,--max: concurrent connections, not requests.--requests: the exact number of requests each connection carries, written one at a time. Pipeline depth is the request test's subject.--no-resume: forhttps://, make every handshake a full one.
Each connection is established, carries its requests and ends; its owner then
establishes the next one, with no delay. A setup is the connect call through
the end of the TLS handshake and has the declared --timeout as its own
deadline; each request's deadline starts when it is written. Setups are
counted apart from requests: attempted, established, failed, timed out. A phase
is accepted when the established share meets --accept and the requests do
too, and a break names which of the two it was.
The client asks to close with Connection: close on a connection's last
request and waits for the server to close. Ending the connection is then the
server's work, as it is with real clients that are told to go away, and the
closing wait state does not pile up on the client's local ports. Running out
of local ports or descriptors is a client failure and claims nothing about the
server.
What a handshake costs the server follows from what is negotiated, so that
is part of the declared load. Each phase prints the version, cipher suite and
key exchange group one of its connections settled on. Without options the TLS
library's default offer is used. --tls-version (1.2 or 1.3),
--tls-group (for example X25519, secp256r1 or the post-quantum hybrid
X25519MLKEM768) and --tls-cipher (for example TLS13_AES_128_GCM_SHA256)
each offer only the named choice, in both tests. A combination the server does
not accept is a setup failure; one that cannot be offered at all is rejected
before any traffic.
Over TLS each connection owner is one visitor with its own session memory. Sessions are resumed by default, as returning clients do; each phase reports how many established connections resumed. A server whose session cache does not serve a returning visitor shows up as full handshakes.
HTTP/2 uses the Sans-I/O client from h2-forked. It shares brk's Mio readiness engine with HTTP/1.1: brk owns socket I/O and request deadlines, while the protocol library handles framing, HPACK, flow control and stream state.
--http2 runs either test over HTTP/2. Over https:// only h2 is offered
during the handshake; over http:// the connection starts with prior
knowledge. A server that does not speak it fails setup.
In the request test the level is then concurrent streams on a connection, where HTTP/1.1 pipelines requests: the connections stay fixed and each phase opens more streams on them. A connection is established once the peer's settings have arrived, within the setup deadline, so no stream is opened on an assumed limit. A peer whose stream limit is below the level leaves the phase unfulfilled, which is its policy and not a capacity break. A stream that misses its deadline is reset and counted as a timeout; the connection and its other streams stay, where an HTTP/1.1 timeout retires the whole connection.
In the connection test each connection carries its declared number of streams, one at a time, and then says goodbye and closes.
Done means the declared ceiling was completed. Broken retains the reason,
last tested phase, last accepted phase and counters. Acceptance failure is distinct
from client I/O/resource failure, invalid protocol data or an unfulfilled target.
Those client/protocol failures do not establish server capacity.
Each phase reports its target and observed mean outstanding requests. The mean is the time integral of fully written requests awaiting complete responses, divided by the active phase time. Setup gaps and connection replacement remain visible; drain is excluded from this integral. Successful requests/s still includes drain.
target-reached-once records first attainment per connection. It does not prove
simultaneous global attainment or sustained load. target-coverage is the observed
mean divided by the target. connection-time-at-target is the fraction of
connection time at full pipeline depth. These are observations, not additional
acceptance thresholds. No undisclosed load tolerance is applied.
Setup attempts/time, blocked writes, maximum send delay and deadline-observation lateness expose generator pressure. They do not independently identify server time, network time or client scheduling delay. A worker thread turns its connections in the order their work arrives; a write burst ends with the free request slots or with a full socket, and reads are served before the next. Both protocols use a Mio readiness loop. Request deadlines are stored by the worker rather than registered as individual runtime timers.
timeout, transport, abandoned, connect, connect-timeout, status and
body are separate failed-attempt categories. Retiring an ordered connection
counts unexpired followers as abandoned; they still fail acceptance. One socket
incident is not reported as an independent transport fault for every follower.
Failed TCP setup counts a logical first-request attempt even though no HTTP
request was written. Local resource failures remain execution failures.
An initial rejection establishes no passing baseline. A rejection after a passing
phase brackets this run's observed boundary, at the declared pace resolution.
The runner does not repeat, refine or claim a confidence interval. An explicit
--max is tested even when it lies between normal pace levels.
On Linux, each phase reports machine-wide CPU use over its loaded window,
from first attainment of the target on every connection to the end of admission.
PhaseStats::machine exposes this reading to library callers. It includes all
processes on the machine, not only the server or client; a phase that never
reaches its target has no loaded-window reading.
--baseline first observes the machine for one --phase without sending test
traffic. This shows background activity beside the loaded measurements; it is
not subtracted from them. Busy CPUs and the remaining share describe observed
usage, not reserved capacity or proof that the application could use every idle
CPU. Process CPU, affinity and container limits must be assessed separately.
Machine CPU readings are currently implemented only on Linux; other platforms
report them as unavailable.
- HTTP/2 has additional protocol and stream-state costs. The client uses a Sans-I/O H2 session on Mio. It retains HPACK, flow control and protocol checks. Header construction and the protocol library's stream ownership still cost CPU. The small cleartext-response benchmark on macOS currently regresses in CPU per response and throughput against the previous async client; scheduling and batching remain under investigation. Use the client benchmark below to measure CPU per response and sustained target coverage.
- The rate can be the client's while every diagnostic is clean. A client
whose threads are full still keeps the declared requests outstanding and
still reads their responses within the deadline, only later. Coverage stays
near 100%, lateness stays small and the phase is accepted. The verdict on
that level stands; requests/s then describes the client, not the server.
brkdoes not report its own CPU use: measure the process, or read it at the phase boundaries through the library, and compare it with the thread count. - Over TLS, HTTP/1.1 makes one read that finds nothing for each response. That is client cost only and changes no outcome.
- Requests are GET or fixed-body POST. No custom headers, scenario file or warmup.
- It has been run on Linux and macOS. Nothing else has been tried.
brk has no scripting language and is not going to get one. Where another
tool takes a script, brk is a library: a bench that needs more than the
command line is a small Rust program of your own, your own brk.
That program decides everything around the load:
- which targets and plans run, and in what order;
- what is read at each phase boundary: the server's CPU and memory, a metrics endpoint, anything the machine can tell you;
- what is written, in which format, and what the process exits with;
- where it runs: on its own, or inside a test suite.
It decides nothing on the request path, and that is the point. A callback per request would be the client's own cost and would change what is measured. Yours run between phases, on the calling thread, and the compiler checks them before a single request is sent. The target is a GET or fixed-body POST with optional exact status and body validation.
[dependencies]
brk = { package = "break", version = "0.2.3", default-features = false }Add features = ["tls", "http2"] when those transports are needed. CLI parsing
is optional and is not required by library callers.
use std::time::Duration;
use brk::{Broken, CancellationToken, Observer, PhaseStats, Plan, Target};
/// One line a phase: what a script would print, typed.
struct Lines(&'static str);
impl Observer for Lines {
fn phase_finished(&mut self, stats: &PhaseStats) {
println!(
"{},{},{:.0},{}",
self.0,
stats.phase.total_concurrency,
stats.requests_per_sec(),
stats.counts.failed()
);
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let plan = Plan {
conn: 256, start: 1, pace: 1,
phase_duration: Duration::from_secs(30),
timeout: Duration::from_millis(100),
accept: 99.99, max_concurrency: Some(16), threads: Some(4),
};
// The same plan against two builds of a server, one after the other.
for (name, url) in [
("before", "http://127.0.0.1:8080/plain"),
("after", "http://127.0.0.1:8081/plain"),
] {
let target = Target::new(url)?.expect_body("Hello, World!");
let stop = CancellationToken::new();
match brk::run(&target, plan, &stop, &mut Lines(name)) {
Ok(done) => println!("{name}: ceiling reached, {} phases", done.stats.phases),
Err(Broken { reason, stats }) => {
let held = stats.last_accepted.map(|phase| phase.phase.total_concurrency);
println!("{name}: {reason}; last accepted {held:?}");
}
}
}
Ok(())
}For a POST target, prepare the body once and enable echo validation if needed:
# use brk::Target;
let target = Target::new("http://127.0.0.1:8080/echo")?
.post_body(std::fs::read("payload.bin")?)
.expect_echo();
# Ok::<(), Box<dyn std::error::Error>>(())run(&target, plan, &cancellation, &mut observer) returns Result<Done, Broken>;
run_connections does the same for the connection test.
Each owns its CPU workers and joins them before returning. Disable the CLI with
default-features = false when embedding the library.
use std::time::Duration;
use brk::{CancellationToken, Observer, Phase, PhaseStats, Plan, Target};
struct Measurements;
impl Observer for Measurements {
fn phase_started(&mut self, phase: &Phase) {
// Read the chosen server or generator counters here.
println!("starting phase {}", phase.index);
}
fn phase_finished(&mut self, stats: &PhaseStats) {
// Read the same counters again and associate their delta with this phase.
println!("phase {}: {} completed attempts", stats.phase.index, stats.counts.attempted);
}
}
let target = Target::new("http://127.0.0.1:8080/plain")?;
let plan = Plan {
conn: 64, start: 2, pace: 2,
phase_duration: Duration::from_secs(10),
timeout: Duration::from_millis(16),
accept: 99.99, max_concurrency: Some(8), threads: None,
};
let result = brk::run(&target, plan, &CancellationToken::new(), &mut Measurements);
# Ok::<(), Box<dyn std::error::Error>>(())workers_started runs once, when the worker threads are ready, with their
number. phase_started runs after worker readiness and before phase traffic, including
connection setup. phase_finished runs after every admitted attempt has finished
or timed out, including rejected and cancelled phases. DNS and CPU-worker startup
are outside these callbacks. The callbacks run synchronously on the caller, never
per request. Downstream can read CPU/RSS counters at these boundaries; generator
CPU and server CPU are separate measurements. Both result variants retain stats.
See ARCHITECTURE.md for module boundaries.
Every phase exposes PhaseStats::latency: the completed-response sample count,
exact sum in nanoseconds, and mean_millis() (None without a sample). The CLI
prints request-avg-ms and the sample count for every phase.
Timing starts when a request is admitted to its connection's write path, after
TCP setup, and ends after the complete HTTP response body is decoded and checked.
It includes write backpressure, queueing, network/response wait and client-side
validation, including drain. It is not server-handler CPU time. TCP setup remains
separate in LoadStats. The first request's existing deadline still includes
connection setup; adding request latency does not change acceptance or deadlines.
All fully decoded replies contribute, including late or status/body-failing responses. Attempts without a complete response have no latency sample and are never added as zero-duration replies; read the error counts alongside the mean. Workers merge sums and counts, rather than averaging connection means. No per-request task, shared counter, allocation or additional clock read is added.
The custom Cargo bench measures the client, using a fixed Native Hyper
responder in another process. Select h1, h2c (prior knowledge) or h2-tls:
mkdir -p .benchmarks
cargo bench --bench client --features fixture -- \
--protocols h2c,h2-tls \
--connections 128 --depths 8 --threads 4 --server-threads 8 \
--phase 10s --timeout 100ms --repeat 2 > .benchmarks/client.csvEach combination is a separate fixed-load phase. There is no implicit warmup;
protocol order reverses on alternate repetitions. CSV rows include throughput,
user/system client CPU, CPU microseconds per attempt and per success, actual
thread count, coverage, errors and responder CPU. CPU counters use Unix
getrusage: the client window excludes target resolution and worker startup,
and includes connection setup, traffic and drain. Responder counters bracket
that window through a control pipe; its CPU is never added to the client's.
The responder uses a shared Tokio runtime with the declared worker count.
Its control thread is also included in server CPU. Both H2 transports use the
same handler and stream limit; TLS additionally verifies an ephemeral fixture
certificate. Every response must be 200 with exactly ok. Certificate
generation and server startup precede measurement; connection handshakes do not.
The best accepted rate is observed throughput, not an isolated client ceiling.
Check errors, coverage and client/responder CPU together. Aggregate responder
CPU cannot rule out one saturated responder thread. Repeat with more
--server-threads, or use --url http://127.0.0.1:8080/plain instead, to check
whether the responder limits the run. An external target must return exactly
ok; select its protocol explicitly. Its CPU is not collected, and external
HTTPS targets use system trust. h2 remains an alias for h2c in this bench.
fixture is optional and adds no server dependencies to the default client
build. The source bench now uses Hyper rather than the earlier raw H1/direct-H2
responder with round-robin current-thread workers. These are different server
baselines; their throughput differences do not establish a client improvement.
brk::fixture provides Config, Process, Responder and serve. A child reads
its configuration from stdin, acknowledges its address and implementation on
stdout, samples its own CPU on request, and drains on stop or control EOF.
Diagnostics use stderr. The optional brk-fixture executable runs the Native
Hyper implementation of this protocol.
A downstream fixture can call serve(start) with its own Responder, including
a Claviron Other handler. Select that executable with --fixture-bin PATH;
the same client matrix and measurement code are retained. Server::start
optionally supplies loopback acceptance and executor ownership for a custom
connection driver. No request dispatch or client logic depends on this API.
Licensed under MIT OR Apache-2.0.