Skip to content

feat(dwctl): accept gzip, zstd and br request bodies and compressed batch files (DW-985) - #2082

Merged
Crown421 merged 5 commits into
mainfrom
steffen/dw-985-accept-compressed-request-bodies-and-return-415-for-content
Oct 22, 2026
Merged

Crown421 merged 5 commits into
mainfrom
steffen/dw-985-accept-compressed-request-bodies-and-return-415-for-content

Conversation

@Crown421

@Crown421 Crown421 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Closes part of DW-985 (compression; the remaining 415s follow in a stacked PR).

Why

Clients can't send us compressed bodies today, and the ones that try get a misleading error. A gzipped /ai/v1/chat/completions body reached the model lookup as raw bytes and came back as a 400 saying the model parameter was missing. A .jsonl.gz batch file failed as "invalid UTF-8 on line 1".

Batch files gain the most. Every line repeats the same system prompt and schema, so a batch file compresses about twice as well with zstd or brotli as with gzip (roughly 23–29× against gzip's 12× on a synthetic file). A single chat request compresses about 3–4× whichever codec is used.

What changes

Realtime inference bodies (/ai/v1/*). limit_inference_body now decodes Content-Encoding: gzip (and x-gzip), zstd and br before any other inference layer reads the body. Inference routing, request logging, translation, the cache and onwards all keep seeing plain JSON, and upstreams receive uncompressed bodies: Content-Encoding is removed and Content-Length is set to the decoded size.

  • Size limit: limits.requests.max_body_size applies to the decoded size, since that is what every inner layer buffers. Decoding stops as soon as the output passes the limit, so a small zip bomb costs at most the limit, and it gets a 413.
  • zstd window: zstd allocates the window a frame declares up front, so without a cap a tiny body could claim gigabytes. The realtime decoder caps it at 8 MiB, which RFC 9659 already forbids zstd content-coding encoders from exceeding. A frame that declares a decoded size over the limit gets a 413 straight away.
  • Unsupported codings: any other coding (deflate, lz4, compress, layered codings such as gzip, br) gets a 415. The response includes Accept-Encoding: gzip, zstd, br (RFC 9110 §15.5.16) and code unsupported_content_encoding.
  • Corrupt data: a body that isn't valid data for its declared coding gets a 400 with code body_decode_failed.
  • Mislabelled plain JSON keeps working. Until now the header was ignored, so a plain JSON body under a wrong or meaningless coding worked, e.g. a proxy's stray gzip or a client's utf-8. Such a body is now passed on as plain and counted in dwctl_inference_bodies_mislabelled_total{declared}, instead of getting the 415 or 400 above. It only takes this path when the body fails to decode, so a real compressed body never does.
  • Bounded decoding: decoding runs before authentication, and a few KB of compressed body can expand to the full limit plus a decoder window. So decodes are limited to one per CPU. Requests over the bound wait for a slot rather than failing, so legitimate traffic is at most delayed, and uncompressed bodies never take a slot.
  • Error format: these errors use the same per-API shape as the existing 413: Anthropic's for /messages, OpenAI's otherwise.
  • Metrics: refusals are recorded in onwards_rejections_total like the other layers' refusals. Successful decodes are counted in dwctl_inference_bodies_decoded_total{encoding}.
  • Blocking work: decoding runs on a blocking thread, since inflating a full-size body takes tens of milliseconds.

Batch file uploads (POST /ai/v1/files). A file part that starts with a gzip or zstd magic number is decoded as it streams in, with async-compression's tokio decoders.

  • max_file_size, the JSONL parser and the stored file all see the decoded content. The reported bytes is the decoded size, and batch.jsonl.gz is stored as batch.jsonl.
  • A truncated or corrupt file gets a 400 that names the format, not a UTF-8 error.
  • zstd window for files: capped at 128 MiB, zstd's own default, so any file the zstd CLI decompresses without extra flags also uploads, including --long files. That window is allocated per upload, but uploads are capped by max_concurrent_uploads. Tighten it if that's too much memory for your deployment.
  • xz, bzip2, zip and lz4 files are recognised and refused with a message naming the format. That's a 400 here; the stacked PR changes it to a 415.

Formats are detected from the content, not from the file name or the part's content type, which are often missing or wrong. No JSONL file can start with any of these signatures.

Not in this PR

  • Further 415s, for non-multipart uploads and content types on JSON endpoints: in the stacked PR.
  • Client side, i.e. compressing by default in autobatcher and documenting a compressing transport for the SDKs: to follow.

Testing

  • inference::body_limit: each coding round-trips, at the limit and with limits disabled. Unknown and layered codings get a 415. Plain JSON under any wrong coding passes through (passes_plain_json_under_a_wrong_content_encoding_through, and end to end in both modes). A decode waits while every slot is taken (decodes_wait_for_a_free_slot). Zip bombs get a 413 for all three codecs. Oversized zstd windows are refused. Corrupt data gets a 400. /messages gets the Anthropic shape. Refusals are counted.
  • upload_compression: gzip (including multi-member) and zstd round-trip whatever the chunking. Truncated and corrupt files report a decode error, and upload errors pass through untouched, so length-limit detection still works. Covers format sniffing and filename stripping.
  • files: compressed uploads end to end (stored name, decoded size, download content), and broken and unsupported compression name the problem.

…atch files (DW-985)

Compressed bodies used to fail with misleading errors: a gzipped chat
request was reported as missing its model, and a .jsonl.gz batch file as
invalid UTF-8 on line 1. Batch files repeat the same prompt on every line
and shrink 10-30x compressed, so accepting them saves customers most of
their upload time.

The inference body limit now decodes gzip, zstd and br before any other
layer reads the body, applies the limit to the decoded size and refuses
other codings with a 415 that lists the accepted ones. File uploads
recognise gzip and zstd by their magic numbers and decode them as they
stream, so the size limit, parser and stored file see decoded content.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Deploying control-layer with  Cloudflare Pages  Cloudflare Pages

Latest commit: e6be2db
Status: ✅  Deploy successful!
Preview URL: https://aca70532.control-layer.pages.dev
Branch Preview URL: https://steffen-dw-985-accept-compre.control-layer.pages.dev

View logs

Copilot AI balanced review requested due to automatic review settings October 21, 2026 19:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing. Review the Copilot code review billing settings.

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 21, 2026

Copy link
Copy Markdown

Running ultrareview automatically — this PR appears to change authentication, authorization, or input validation, where a missed bug can expose data or grant the wrong access, so a deeper multi-pass review is worth running. I'll post findings when complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultrareview ran automatically — this PR appears to change authentication, authorization, or input validation, where a missed bug can expose data or grant the wrong access, so a deeper multi-pass review is worth running.

All reported issues were addressed across 9 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread dwctl/src/upload_compression.rs
Comment thread dwctl/src/api/handlers/files.rs
Comment thread docs/src/reference/configuration.md Outdated
…(DW-985)

brotli::Decompressor accepts the large-window extension, whose window of
up to 1 GiB is allocated before any output, so a tiny body could exhaust
memory past the output bound. Decode with the strict state, which keeps
RFC 7932's 16 MiB ceiling.

async-compression's zstd decoder stopped after the first frame, so a
file of concatenated frames was stored truncated. Read them all, and
recognise a stream that opens with a skippable frame as zstd.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread dwctl/src/inference/body_limit.rs
Every other codec reads at most max_bytes + 1 decoded bytes; brotli could
buffer a further 64 KiB chunk before checking the limit.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Requires human review: Adds gzip/zstd/br decoding for /ai/v1 inference bodies and gzip/zstd batch uploads in dwctl, with new size limits and 413/415/400 handling. This is a large production request-path feature, so it needs an owner's sign-off.

View guided diff | Turn on auto-fix | Re-trigger cubic

… decodes (DW-985)

Before compression was supported the Content-Encoding header was
ignored, so a plain JSON body under a wrong or meaningless coding (a
proxy's stray gzip, a client's utf-8) worked. Refusing it now would break
those clients for nothing: pass it on as plain and count it instead.

Decoding runs before authentication, and a few kilobytes of compressed
body can expand to the body limit plus a decoder window. Bound decodes
to one per CPU; requests over the bound wait rather than fail, so
legitimate traffic is only delayed and uncompressed bodies never queue.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread dwctl/src/inference/body_limit.rs
Comment thread dwctl/src/inference/body_limit.rs Outdated
… (DW-985)

A request dropped mid-decode (the client went away) released its slot
while the blocking decode ran on, so disconnecting clients could exceed
the one-per-CPU bound. Move the permit into the blocking task.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

View guided diff | Turn on auto-fix | Re-trigger cubic

@pjb157
pjb157 added this pull request to stack #2088 October 22, 2026 11:05
@Crown421
Crown421 added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 673a64c Oct 22, 2026
27 checks passed
github-merge-queue Bot pushed a commit that referenced this pull request Oct 22, 2026
…pes (DW-985) (#2083)

Stacked on #2082. Completes DW-985.

## Why

RFC 9110 §15.5.16 reserves 415 for a request whose content format or
coding the server doesn't support. #2082 returns it for unsupported
content codings. Three other refusals in the same class still came back
as a 400, which tells the client its request was malformed rather than
sent in the wrong format:

- **`POST /ai/v1/files` without `multipart/form-data`,** e.g. a raw
JSONL body. This was a 400 "Invalid multipart request: invalid
boundary", and it took an upload slot before being refused.
- **A batch file compressed with xz, bzip2, zip or lz4.** #2082 names
the format but returned a 400.
- **Strict-mode `/responses` with a body not declared as JSON.** This
was a 400 `invalid_content_type`.

All three already fail today, so changing the status breaks no working
client.

## What changes

- **dwctl:** a new `Error::UnsupportedMediaType` variant, returned as a
415.
- **File uploads:** a request without a `multipart/form-data` content
type gets a 415 before it takes an upload slot. A multipart request with
a broken form, such as a bad boundary, is still a 400. Files compressed
in a format we don't decode also get a 415.
- **onwards, strict `/responses`:** a missing JSON content type returns
415. The code stays `invalid_content_type`, so
`onwards_rejections_total` series keep their label.
- **New metric:**
`dwctl_inference_request_content_types_total{content_type}` counts the
content type declared on each POST to the inference endpoints, i.e.
everything under `/ai/v1/*` except the file and batch routes, which
already check theirs. Values are grouped into json, form, multipart,
text, other and none. `/chat/completions`, `/completions` and
`/embeddings` parse the body whatever it declares, and `curl -d` without
`-H` declares a form body. Rejecting on content type there could break
working clients, so this measures how many such requests there are
before anyone decides.

## Not in this PR

- **Rejecting non-JSON content types on the lenient endpoints:** a
follow-up, once the new metric shows how much traffic it would affect.
- **Non-UTF-8 `charset` parameters:** deliberately left alone for the
same reason.

## Testing

- `test_upload_that_is_not_a_multipart_form_returns_415`: a JSONL body,
a form body and a missing content type each get a 415. A multipart body
with a broken form still gets a 400.
- `test_upload_broken_or_unsupported_compression_names_the_problem`: xz
gets a 415, and truncated gzip still gets a 400.
- `responses_bodies_not_sent_as_json_are_counted_as_such` in onwards:
now asserts a 415 and the unchanged rejection code.
- `classifies_declared_content_types`: covers the metric's labels.

Co-authored-by: Steffen Ridderbusch <steffen@doubleword.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants