Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
fb495ee
fix(gateway): derive the body limit, reject breach 413
cuioss-oliver Jul 29, 2026
361dfed
fix(benchmarks): bind the TOPOLOGY_UPSTREAM override
cuioss-oliver Jul 29, 2026
599931b
test(gateway): pin the body-limit descriptor activation
cuioss-oliver Jul 29, 2026
a4276f4
test(integration): add the containerised large-body regression IT
cuioss-oliver Jul 29, 2026
373e758
docs(gateway): reconcile the body-cap reference and the 413 error con…
cuioss-oliver Jul 29, 2026
d85011a
chore(benchmarks): re-enable the uploadLarge goal and de-quarantine i…
cuioss-oliver Jul 29, 2026
f9112fa
style(build): apply quality-gate formatter normalization to imports a…
cuioss-oliver Jul 29, 2026
70cb70a
fix(tests): use typed getString in the LargeBodyIT problem assertions
cuioss-oliver Jul 29, 2026
0f1e865
docs(config): make the max_body_bytes RFC 9457 envelope promise condi…
cuioss-oliver Jul 29, 2026
5209ab5
test(events): close the EventType coverage gaps and guard against fut…
cuioss-oliver Jul 29, 2026
e3773a6
fix(ci): correct the stale executing-goal count above the coverage step
cuioss-oliver Jul 29, 2026
b85c57a
test(gateway): assert the 413 status contract in the large-body IT
cuioss-oliver Jul 30, 2026
de169eb
ci(benchmarks): derive the coverage gate from a generated execution m…
cuioss-oliver Jul 30, 2026
eeec70b
ci(benchmarks): call the generator's summarise instead of re-deriving…
cuioss-oliver Jul 30, 2026
d984daf
test(events): derive EventTypeTest bucket membership from the enum
cuioss-oliver Jul 30, 2026
500eec3
docs(adr): record the body-cap contract as ADR-0023
cuioss-oliver Jul 30, 2026
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
104 changes: 37 additions & 67 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,19 @@ jobs:
# The -Pbenchmark profile DECLARES TWELVE k6 goals: the eight cross-gateway matrix aspects, the
# two retained non-matrix health benchmarks (healthLiveCheck, gatewayHealth), and the two
# API-Sheriff-only passthrough-relay executions (mapped relay throughput and the empty-mode
# no-regression run). ELEVEN of them execute: only uploadLarge still carries a <skip> bound to a
# property that defaults to true in benchmarks/pom.xml, because it remains blocked by an open
# gateway defect (see the "Deliberately skipped" table in the coverage step below). Maven
# therefore never runs it, so it consumes no wall time and cannot abort the suite. It stays
# ordered last in the profile as defense-in-depth, so that flipping its skip property to false
# cannot mask a goal that would otherwise produce a result.
# no-regression run). All TWELVE of them execute: uploadLarge and websocketEcho still carry a
# <skip> bound to their own property, but both properties are false in benchmarks/pom.xml, so
# both goals run. The two stay ordered last in the profile as defense-in-depth, so a fail-fast
# abort from either cannot discard a goal that would otherwise produce a result.
#
# Budget, from per-goal timings measured locally 2026-07-28 rather than estimated:
# * goal execution ~13 min -- sized on the ELEVEN goals that actually execute, which is the
# real basis: a skipped goal is not run at all, so it costs nothing but the
# Maven bookkeeping for a skipped execution.
# 11 goals x (60s k6 window + ~10s compose/k6 start and summary write).
# The websocketEcho term is the ~70s a re-enabled request-rate goal costs,
# now that its blocking permit leak is fixed. Re-enabling upload-50MB would
# cost more than that: it is transfer-bound at reduced concurrency, so it
# does not finish in the same wall time as a request-rate run. Re-derive
# this term when a skip property is flipped to false.
# * goal execution ~16 min -- sized on all TWELVE goals, which now all execute.
# 11 request-rate goals x (60s k6 window + ~10s compose/k6 start and
# summary write) = ~13 min, plus uploadLarge. uploadLarge is NOT a
# request-rate goal: it is transfer-bound at reduced concurrency
# (k6.vus.upload.large = 5), so it does not finish in the same wall time as
# a request-rate run — it is budgeted at ~3 min on its own. Re-derive this
# term when a skip property is flipped, or when a goal is added or removed.
# * stack startup ~100 s -- the lane boots FIVE native gateway instances (api-sheriff,
# api-sheriff-mtls, api-sheriff-cookie, api-sheriff-cookie-2 and
# api-sheriff-ws-admission, the last added for the WebSocket relay-permit
Expand Down Expand Up @@ -119,10 +115,8 @@ jobs:
# twelve goals: the eight cross-gateway matrix aspects (unauth, bearer, http2,
# graphql, upload-1MB, upload-50MB, ws, grpc), the two retained non-matrix health
# benchmarks (healthLiveCheck, gatewayHealth), and the two API-Sheriff-only
# passthrough-relay executions (mapped and empty). Eleven of the twelve execute and are
# expected to produce a CI result; only upload-50MB (uploadLarge) is still skipped by
# property against an open gateway defect, so Maven does not run it and it cannot abort
# the suite. The on-demand APISIX comparison lane is deliberately NOT run
# passthrough-relay executions (mapped and empty). All twelve execute and are
# expected to produce a CI result. The on-demand APISIX comparison lane is deliberately NOT run
# here. Maven still fails fast on the goals that do run, so the coverage step below
# remains what proves which goals actually produced a result.
echo "Running k6 integration benchmarks with native Quarkus..."
Expand All @@ -134,61 +128,37 @@ jobs:
ls -la benchmarks/target/benchmark-results/

# Maven fails fast, so a goal that errors aborts every goal after it and a goal that is never
# reached leaves no trace in the job output — a suite that ran 3 of the 11 executing goals
# renders exactly like one that ran all 11. This step diffs the goals that MUST produce a
# reached leaves no trace in the job output — a suite that ran a handful of the executing goals
# renders exactly like one that ran all of them. This step diffs the goals that MUST produce a
# summary document against the documents k6 actually wrote, so a partial suite is visibly
# partial and a goal that silently vanished fails the job. When Maven failed, it also states
# in words whether the suite was truncated by that failure. Skipped goals are named with their
# reason and their skip property rather than omitted, so "not run" is never
# indistinguishable from "forgotten".
# in words whether the suite was truncated by that failure. Goals that are not expected to run
# are named with their reason and their skip property rather than omitted, so "not run" is
# never indistinguishable from "forgotten".
#
# Both the expected set and the not-expected table are DERIVED from the build-generated
# execution manifest (benchmarks/pom.xml, generate-benchmark-manifest, bound to initialize),
# not maintained by hand here. That is deliberate: the previous hand-maintained list mirrored
# the POM with nothing enforcing agreement, and it drifted — the goal set moved from eleven to
# twelve while prose in this file still said eleven. Deriving both from the manifest makes that
# drift structurally impossible, and an absent manifest is itself a hard failure rather than an
# empty expected set that would vacuously report full coverage.
#
# The rendering lives in the generator's own `summarise` subcommand rather than being
# reimplemented in bash here, so the manifest has exactly ONE consumer. Splitting it across two
# implementations would recreate, one level up, the duplicated-contract defect this manifest was
# introduced to remove — and did: an earlier bash/jq version of this step diverged on the table
# heading and mislabelled an inline `<skip>` as "not wired".
- name: Summarise benchmark coverage
if: always()
env:
BENCHMARK_OUTCOME: ${{ steps.run-benchmarks.outcome }}
run: |
set -uo pipefail
results_dir=benchmarks/target/benchmark-results/k6

# Goals expected to write a summary. A goal settled as deliberately skipped is NOT listed
# here — it belongs in the skip table below, which is what keeps the two states distinct.
expected="healthLiveCheck gatewayHealth proxiedStatic passthroughRelay passthroughRelayEmpty bearerProxied http2 graphql uploadSmall grpcUnary websocketEcho"

missing=0
missing_names=""
{
echo "## Benchmark coverage"
echo
echo "| Benchmark | Result |"
echo "| --- | --- |"
} >> "$GITHUB_STEP_SUMMARY"

for name in ${expected}; do
if [ -f "${results_dir}/${name}-summary.json" ]; then
echo "| \`${name}\` | ran |" >> "$GITHUB_STEP_SUMMARY"
else
echo "| \`${name}\` | **DID NOT RUN** |" >> "$GITHUB_STEP_SUMMARY"
missing=$((missing + 1))
missing_names="${missing_names:+${missing_names}, }${name}"
fi
done

{
echo
echo "### Deliberately skipped"
echo
echo "| Benchmark | Skipped by | Reason |"
echo "| --- | --- | --- |"
echo "| \`uploadLarge\` | \`skip.benchmark.upload.large\` (defaults to \`true\`) | The gateway rejects the 50MB body with 413: the Quarkus HTTP body limit is not derived from the upload anchor's 64 MiB \`max_body_bytes\`, so the declared cap is unreachable. Needs a gateway-side fix; re-enable with \`-Dskip.benchmark.upload.large=false\`. |"
echo "| \`sessionMediated\` | not wired | Wired to no Maven goal on purpose — BFF session mediation belongs to PLAN-07A. |"
} >> "$GITHUB_STEP_SUMMARY"

# Only fail on a missing summary when Maven itself reported success: when the benchmark
# step already failed, its own error is the actionable signal and this step must not
# mask it with a derived one.
if [ "${missing}" -gt 0 ] && [ "${BENCHMARK_OUTCOME}" = "success" ]; then
echo "::error::${missing} expected benchmark summary document(s) absent although the benchmark step succeeded."
exit 1
fi
python3 benchmarks/scripts/benchmark-manifest.py summarise \
--manifest benchmarks/target/benchmark-execution-manifest.json \
--results-dir benchmarks/target/benchmark-results/k6 \
--benchmark-outcome "${BENCHMARK_OUTCOME}" \
--summary-file "$GITHUB_STEP_SUMMARY"

# When Maven failed, the DID NOT RUN rows above and the Maven error are almost always the
# same event, but nothing on the page says so — a reader is left to infer the causal link.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
* {@link ByteCappedBodyStream} that forwards each chunk to the upstream request as it arrives and
* enforces the {@code max_body_bytes} ceiling with a running counter. A mid-stream breach ABORTS
* the in-flight upstream call (Vert.x {@link HttpClientRequest#reset()}) and surfaces
* {@link EventType#PARAMETER_LIMIT_EXCEEDED} (400). The upstream body is <strong>never
* {@link EventType#CONTENT_TOO_LARGE} (413). The upstream body is <strong>never
* materialized</strong> into an {@code HttpResult<byte[]>} (ADR-0006/0008): the returned
* {@link HttpClientResponse} is a live {@link ReadStream} whose body {@link ResponseStage} streams
* back with backpressure.
Expand Down Expand Up @@ -283,7 +283,7 @@ private static String stripTrailingSlash(String value) {
* A {@link ReadStream} decorator that forwards each request-body chunk to the upstream as it
* arrives — never accumulating the body — while counting bytes against a ceiling. On breach it
* aborts the in-flight upstream request and fails the stream with a
* {@link EventType#PARAMETER_LIMIT_EXCEEDED} {@link GatewayException}.
* {@link EventType#CONTENT_TOO_LARGE} {@link GatewayException}.
*/
static final class ByteCappedBodyStream implements ReadStream<Buffer> {

Expand Down Expand Up @@ -320,7 +320,7 @@ private void onChunk(Buffer chunk) {
aborted = true;
delegate.pause();
abortAction.run();
propagateFailure(new GatewayException(EventType.PARAMETER_LIMIT_EXCEEDED,
propagateFailure(new GatewayException(EventType.CONTENT_TOO_LARGE,
"Request body exceeded max_body_bytes=" + maxBytes));
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
* <li>{@code 403} → {@code PERMISSION_DENIED} (7)</li>
* <li>{@code 404} → {@code NOT_FOUND} (5)</li>
* <li>{@code 405} → {@code UNIMPLEMENTED} (12)</li>
* <li>{@code 413} → {@code RESOURCE_EXHAUSTED} (8)</li>
* <li>{@code 502} / {@code 503} → {@code UNAVAILABLE} (14) — also an h2-negotiation failure</li>
* <li>{@code 504} → {@code DEADLINE_EXCEEDED} (4)</li>
* <li>anything else → {@code UNKNOWN} (2)</li>
Expand All @@ -62,6 +63,8 @@ public final class GrpcStatusMapper {
public static final int NOT_FOUND = 5;
/** gRPC status code: the caller lacked permission (maps HTTP 403). */
public static final int PERMISSION_DENIED = 7;
/** gRPC status code: a per-request resource bound was exhausted (maps HTTP 413). */
public static final int RESOURCE_EXHAUSTED = 8;
/** gRPC status code: the operation is not implemented / not supported (maps HTTP 405). */
public static final int UNIMPLEMENTED = 12;
/** gRPC status code: the service is unavailable (maps HTTP 502 / 503 and h2-negotiation failure). */
Expand Down Expand Up @@ -90,6 +93,7 @@ public int toGrpcStatus(EventType eventType) {
case 403 -> PERMISSION_DENIED;
case 404 -> NOT_FOUND;
case 405 -> UNIMPLEMENTED;
case 413 -> RESOURCE_EXHAUSTED;
case 502, 503 -> UNAVAILABLE;
case 504 -> DEADLINE_EXCEEDED;
default -> UNKNOWN;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,24 @@ public enum EventType {
* {@code reserved_body_max_bytes} ceiling. These paths are read before the pipeline's per-route
* body cap can apply, so the ceiling is enforced at the read itself and the request is rejected
* {@code 413} without the oversized body ever being buffered.
* <p>
* Contrast {@link #CONTENT_TOO_LARGE}, which is the <em>per-route</em>
* {@code security_filter.max_body_bytes} cap on the proxy path. This event is exclusively the
* edge-level {@code reserved_body_max_bytes} ceiling on gateway-terminated reserved paths.
*/
RESERVED_BODY_TOO_LARGE(EventCategory.INPUT_VALIDATION, 413),
/**
* A proxied request declared or streamed a body beyond its route's
* {@code security_filter.max_body_bytes} cap. The cap has two enforcement points: the
* {@code Content-Length} fast-reject in {@code ThoroughChecksStage}, which rejects before any
* body is read, and the streaming byte counter in {@code DispatchStage}, which aborts a chunked
* or under-declared body mid-transfer once the cap is crossed.
* <p>
* Contrast {@link #RESERVED_BODY_TOO_LARGE}, which is the edge's {@code reserved_body_max_bytes}
* ceiling on gateway-terminated reserved BFF POST paths. This event is exclusively the per-route
* proxy-path cap.
*/
CONTENT_TOO_LARGE(EventCategory.INPUT_VALIDATION, 413),

// --- Authentication (401) ---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@
* whitelist, the canonical path must match one pattern, where a {@code {name}} segment matches
* exactly one path segment; a miss is a 400 {@link EventType#PATH_NOT_ALLOWED}.</li>
* <li><strong>{@code max_body_bytes} fast-reject.</strong> A declared {@code Content-Length}
* already exceeding the route config's {@code maxBodySize} is rejected 400
* ({@link EventType#PARAMETER_LIMIT_EXCEEDED}) before the body is read.</li>
* already exceeding the route config's {@code maxBodySize} is rejected 413
* ({@link EventType#CONTENT_TOO_LARGE}) before the body is read.</li>
* </ul>
*
* @author API Sheriff Team
Expand Down Expand Up @@ -76,7 +76,7 @@ public ThoroughChecksStage(SecurityConfiguration defaultConfiguration, SecurityE
* @param allowedPaths the selected route's {@code allowed_paths} whitelist, empty when unrestricted
* @throws GatewayException on a divergent-pipeline violation ({@link EventType#SECURITY_FILTER_VIOLATION}),
* a whitelist miss ({@link EventType#PATH_NOT_ALLOWED}), or a body-cap breach
* ({@link EventType#PARAMETER_LIMIT_EXCEEDED})
* ({@link EventType#CONTENT_TOO_LARGE})
*/
public void process(PipelineRequest request, List<String> allowedPaths) {
Objects.requireNonNull(request, "request");
Expand Down Expand Up @@ -119,7 +119,7 @@ private void reRunPipelines(PipelineRequest request, SecurityConfiguration route
private static void enforceBodyCap(PipelineRequest request, SecurityConfiguration routeConfig) {
long cap = routeConfig.maxBodySize();
if (request.declaredContentLength() > cap) {
throw new GatewayException(EventType.PARAMETER_LIMIT_EXCEEDED,
throw new GatewayException(EventType.CONTENT_TOO_LARGE,
"Declared body %d exceeds route cap %d".formatted(request.declaredContentLength(), cap));
}
}
Expand Down
Loading
Loading