fix(openai_responses): centralize body-size enforcement - #700
fix(openai_responses): centralize body-size enforcement#700eoinfennessy wants to merge 3 commits into
Conversation
Several OpenAI Responses filters carried a `max_body_bytes` knob that only sized their `StreamBuffer` buffer. Because praxis core merges per-direction body modes across a chain (keeping the largest) before clamping to the listener's transport ceiling, that knob was silently widened whenever a sibling filter declared a larger buffer — so it never reliably capped the raw body it appeared to control (issue praxis-proxy#562). Raw transport size is now governed exclusively by the pipeline's `body_limits` (`max_request_bytes` / `max_response_bytes`), enforced once by core before any filter runs. Per-filter limits are reserved for the body a filter genuinely *produces*, which `body_limits` cannot see. - Add shared `body_limits` helpers: `validate_size_limit` (names the actual field) and `reject_rewritten_body_too_large` (streaming-aware 413). - Producer filters (doc_extract, file_resolve, openai_mcp_tool_resolve, openai_responses_proxy) enforce `max_rewritten_body_bytes` / `max_resolved_bytes` on the rewritten body only; buffers accept up to the 64 MiB absolute ceiling. - Pure-BodyMode filters (openai_responses_format, openai_responses_model_rewrite, agentic_loop, openai_mcp_dispatch, openai_tool_parse) drop the knob entirely and rely on `body_limits`. - responses_to_chat_completions renames `max_body_bytes` -> `max_rewritten_body_bytes` to reflect that it bounds the translated body. Removed fields are now rejected under `deny_unknown_fields` (a breaking config change surfaced loudly at startup rather than silently ignored). Add the body-size-limits example config and its functional integration test; update example configs, filter docs, and unit/integration coverage. Signed-off-by: Eoin Fennessy <efenness@redhat.com> rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED
praxis-bot
left a comment
There was a problem hiding this comment.
Reviewed all 47 changed files across the centralized body-size enforcement migration.
Approach: Sound. Raw transport size enforcement moves to body_limits (pipeline-level), while producer filters retain a renamed max_rewritten_body_bytes / max_resolved_bytes for the body they produce. This correctly separates transport caps from semantic caps.
Checked:
body_limits.rs:validate_size_limitcorrectly rejects zero and above-ceiling values;reject_rewritten_body_too_largedelegates to the streaming-awareresponses_error_rejectionenvelope. Tests cover zero, above-ceiling, at-ceiling, non-streaming JSON, and streaming SSE.- All pure-BodyMode filters (responses_format, model_rewrite, agentic_loop, mcp_dispatch, tool_parse) correctly drop
max_body_bytesand declareStreamBuffer { max_bytes: Some(MAX_JSON_BODY_BYTES) }. Legacymax_body_bytesis rejected at parse time viadeny_unknown_fields(verified tests exist for each). - All producer filters (doc_extract, file_resolve, mcp_tool_resolve, responses_proxy, responses_to_chat_completions) correctly rename to
max_rewritten_body_bytes/max_resolved_bytes, validate through the centralized helper, and enforce only on the produced body. - Pipeline test (
resolve_pipelines_transport_limit_governs_openai_responses_raw_body) proves the transport cap clamps the merged 64 MiB StreamBuffer declarations. - Integration test verifies both under-cap forwarding (200) and over-cap rejection (413).
- Streaming detection is consistent across all filters (metadata from
openai_responses_format.stream). - Error types and status codes are consistent: 413 /
invalid_request_errorfor oversized rewritten bodies. - Example config, docs, and README updates are in sync.
No issues found at critical, large, or medium severity.
praxis-bot
left a comment
There was a problem hiding this comment.
Reviewed the merge commit (c332136) that integrates nine main-branch changes into this PR.
Merge brought in two unrelated features from main:
mcp_dispatch:normalize_arguments/extract_argumentsrefactor (argument parsing, double-encoding fix). No interaction with body-size enforcement.responses/mod.rs: authoritativePOST /v1/responsesclassification (discriminator-less create bodies classify asopenai_responsesinstead ofunknown_json). Newclassify_requestlogic, tests, example config docs, integration tests.
Checked:
mcp_dispatch/mod.rsmerge:normalize_argumentsand the body-size changes (MAX_JSON_BODY_BYTESceiling,max_body_bytesfield removal) coexist cleanly. No conflict artifacts.responses/mod.rsmerge:body::MAX_JSON_BODY_BYTESimport andrequest_body_modeceiling change merged correctly alongside the newis_responses_createimport andclassify_requestrewrite.responses/tests.rs:run_filter_rawnow delegates torun_filter_raw_with_methoddefaulting toPOST /v1/responses. The PR'slegacy_max_body_bytes_rejectedandbody_mode_is_stream_buffertests are unaffected because they exercise config parsing and body mode, not classification.format-routing.yaml,openai_responses_format.md, integration test updates from main are documentation/config changes with no body-size overlap.- Infrastructure changes (
Cargo.lock,deny.toml, CI workflow) are orthogonal.
Note: mergeable_state: dirty -- main is 9 commits ahead since this merge. The branch will need another merge or rebase before it can land.
No new issues found in the merge commit.
praxis-bot
left a comment
There was a problem hiding this comment.
Reviewed all 48 changed files across the centralized body-size enforcement migration.
Approach: Sound. Separating raw transport caps (body_limits) from per-filter produced-body caps (max_rewritten_body_bytes / max_resolved_bytes) is the correct architecture. The old max_body_bytes knob was misleading because core merged per-direction body modes across the chain before clamping to the transport ceiling, making per-filter raw caps unreliable.
Checked:
body_limits.rshelpers:validate_size_limitcorrectly rejects zero and above-ceiling values;reject_rewritten_body_too_largebuilds streaming-aware 413s via the sharedresponses_error_rejectionenvelope. Unit tests cover all branches.- All pure-BodyMode filters (
responses_format,model_rewrite,agentic_loop,mcp_dispatch,tool_parse) correctly dropmax_body_bytesand declareStreamBuffer { max_bytes: Some(MAX_JSON_BODY_BYTES) }. Legacy field rejected viadeny_unknown_fieldswith test coverage for each. - All producer filters (
doc_extract,file_resolve,mcp_tool_resolve,responses_proxy,responses_to_chat_completions) correctly rename tomax_rewritten_body_bytes/max_resolved_bytes, validate throughvalidate_size_limit, and enforce only on the produced body. file_resolve: newmax_resolved_bytesproperly decouples the per-file content limit from the total rewritten body limit. Previously both were overloaded ontomax_body_bytes. Tests verify independence (max_resolved_bytes_bounds_individual_content_independent_of_rewritten_limit).mcp_tool_resolve: newResolveError::RewrittenTooLargevariant added with 413 mapping inresolve_error_rejection. Previously this filter had no rewritten body size check at all -- this is a genuine improvement.- Raw body checks removed from
doc_extractandfile_resolve(reject_raw_body_too_large) -- correctly delegated to the pipeline transport cap. - Pipeline test (
resolve_pipelines_transport_limit_governs_openai_responses_raw_body) proves the 64 MiB StreamBuffer declarations are clamped tobody_limits.max_request_bytes. - Integration test covers both under-cap forwarding (200) and over-cap rejection (413).
- Streaming detection is consistent across all producer filters (
openai_responses_format.streammetadata). - Error types and status codes are consistent: 413 /
invalid_request_errorfor oversized rewritten bodies. - Example config, filter docs, and README updates are in sync.
One medium observation noted inline.
|
|
||
| serialized.commit(body, filter_name, "tools"); | ||
| if serialized.len() > max_rewritten_body_bytes { | ||
| debug!( |
There was a problem hiding this comment.
medium / consistency: This new oversized-body rejection path uses debug!, while doc_extract and file_resolve use warn! for the identical semantic event (rewritten body exceeds configured limit). At default log levels operators would see warnings for doc_extract/file_resolve rejections but miss mcp_tool_resolve rejections.
Note: responses_proxy and responses_to_chat_completions also use debug! for their equivalent paths, so the inconsistency is partially pre-existing. But since this PR centralizes body-size enforcement, it is an opportunity to align all producer filters on one level.
Suggestion:
warn!(
actual = serialized.len(),
limit = max_rewritten_body_bytes,
"rewritten request body exceeds configured limit"
);This would also align the tracing field names (actual/limit vs body_bytes/max_bytes) with the convention used in the other two producer filters.
Motivation
Several OpenAI Responses filters carried a
max_body_bytesknob that only sized theirStreamBufferbuffer. Because praxis core merges per-direction body modes across a chain (keeping the largest) before clamping to the listener's transport ceiling, that knob was silently widened whenever a sibling filter declared a larger buffer — so it never reliably capped the raw body it appeared to control.Closes #562.
Approach
Raw transport size is now governed exclusively by the pipeline's
body_limits(max_request_bytes/max_response_bytes), enforced once by core before any filter runs. Per-filter limits are reserved for the body a filter genuinely produces, whichbody_limitscannot see.apis/src/openai/responses/body_limits.rs):validate_size_limit(names the actual field in errors) andreject_rewritten_body_too_large(streaming-aware 413).doc_extract,file_resolve,openai_mcp_tool_resolve,openai_responses_proxy) enforcemax_rewritten_body_bytes/max_resolved_byteson the rewritten body only; their buffers accept up to the 64 MiB absolute ceiling.openai_responses_format,openai_responses_model_rewrite,agentic_loop,openai_mcp_dispatch,openai_tool_parse) drop the knob entirely and rely onbody_limits.responses_to_chat_completionsrenamesmax_body_bytes→max_rewritten_body_bytesto reflect that it bounds the translated body, not the raw input.Breaking change
Removed fields are now rejected under
#[serde(deny_unknown_fields)]. A config that still setsmax_body_byteson a migrated filter fails loudly at startup rather than silently ignoring the value. Example configs and filter docs are updated to the new form.Testing
body-size-limitsexample config plus a functional integration test asserting the transport cap rejects oversized raw bodies with 413 and forwards small ones.make testandmake lintpass; filter docs, example README, and coverage manifests regenerated and in sync.