Volga
Home
Agent Skill
API Docs
GitHub
  • English
  • Русский
Home
Agent Skill
API Docs
GitHub
  • English
  • Русский
  • Home
  • Getting Started

    • Quick Start
    • Handlers
    • Route Parameters
    • Query Parameters
    • Route Groups
  • Requests & Responses

    • Headers
    • Handling JSON
    • Handling Form Data
    • Working with Files
    • Multipart Responses
    • Raw Request Body
    • Cookies
    • Validating Requests
  • Middleware & Infrastructure

    • Basic Middleware
    • Custom Middleware
    • Parameterized Middleware
    • Response Compression
    • Request Decompression
    • CORS (Cross-Origin Resource Sharing)
    • Static Files
    • Rate Limiting
    • Configuration Files
    • OpenAPI
  • Security & Access

    • Authentication and Authorization
    • OAuth 2.1 & OpenID Connect
    • OAuth 2.1 Client
    • Machine-to-Machine Grants
    • DPoP — Sender-Constrained Tokens
  • Reliability & Observability

    • Global Error Handling
    • Tracing & Logging
    • Request cancellation
    • Graceful Shutdown
  • Protocols & Realtime

    • HTTP Versions and Methods
    • HTTPS
    • WebSockets
    • Server-Sent Events (SSE)
  • Advanced Patterns

    • Dependency Injection
    • Custom Handling of HEAD, OPTIONS, and TRACE Methods
  • Agent Skill

Raw Request Body

Most handlers take a typed extractor — Json<T>, Form<T>, Multipart — and never touch the bytes underneath. When you do need them (a proxy, a webhook whose signature covers the exact payload, a custom wire format), Volga exposes the body itself.

Reading the Body as Bytes

Starting with v0.9.4, HttpBody is an extractor — take it as a handler argument to get the raw request body:

[dependencies]
volga = { version = "..." }
tokio = { version = "...", features = ["full"] }
http-body-util = "0.1"
use http_body_util::BodyExt;
use volga::{App, HttpBody, ok};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new();

    app.map_post("/raw", |body: HttpBody| async move {
        let bytes = body.collect().await?.to_bytes();
        ok!(format!("received {} bytes", bytes.len()))
    });

    app.run().await
}

Collecting the body needs the Body trait methods, which come from the BodyExt extension trait of http-body-util — Volga does not re-export it.

Warning

The request body is a stream that can be consumed only once, so HttpBody cannot be combined with another body extractor in the same handler. Extractors that read only the request head — Path<T>, Query<T>, HttpHeaders, Dc<T> — combine with it freely.

Tips

The body limit still applies: by default Volga answers 413 Content Too Large to a request body over 5 MB, however the handler reads it. See Limiting the Body Size for setting it per application, route group or route.

Passing the Body Through

HttpBody can also be returned, which makes a pass-through handler a matter of moving it into the response — nothing is buffered:

use volga::{App, HttpBody, HttpResponse, headers::ContentType};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new();

    app.map_post("/trace", |body: HttpBody| async move {
        HttpResponse::builder()
            .status(200)
            .header(ContentType::multipart_form_data("X-BOUNDARY"))
            .body(body)
    });

    app.run().await
}

Streaming the Body

To process the body as it arrives instead of collecting it, take HttpBodyStream — a ByteStream over the request body, which implements Stream<Item = Result<Bytes, Error>>:

[dependencies]
volga = { version = "..." }
tokio = { version = "...", features = ["full"] }
futures-util = "0.3"
use futures_util::StreamExt;
use volga::{App, http::HttpBodyStream, ok};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new();

    app.map_post("/count", |mut stream: HttpBodyStream| async move {
        let mut total = 0;
        while let Some(chunk) = stream.next().await {
            total += chunk?.len();
        }
        ok!(format!("received {total} bytes"))
    });

    app.run().await
}

Use this for bodies you would rather not hold in memory at once — hashing an upload, forwarding chunks to storage, parsing a line-delimited stream.

Limiting the Body Size

Every request body has a limit — 5 MB unless you set another. A body over it is answered 413 Content Too Large, whichever way the handler reads it: Json<T>, Form<T>, File, Multipart, HttpBody or a stream.

Since 0.14.0 the message is the same whichever reader it is, request body is larger than the limit, so it reaches map_err() and the client as it is. Json<T> and Form<T> no longer put JSON parsing error or Form Data parsing error in front of it: a body refused for its size, often before a byte of it was read, was never parsed. The same goes for any other failure to read the body, while a body that does not deserialize is still a parsing error. The limit itself is not in the message; HttpRequest::body_limit() reports it, for a handler or a map_err() that wants to tell the client.

The application-wide limit is set with with_body_limit(), or with the body_limit_bytes key of the [server] section in a configuration file:

use volga::{App, Limit};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // 1 MB for every request body
    let app = App::new().with_body_limit(Limit::Limited(1024 * 1024));

    app.run().await
}

Per Route Group and per Route

Since 0.13.1 a route group and a single route can set a limit of their own with RouteGroup::with_body_limit() and Route::with_body_limit(). It replaces the application's, so it can raise the limit as well as lower it — an API keeps a tight limit for its JSON endpoints and still takes files on the one route that needs them:

use futures_util::StreamExt;
use volga::{App, Json, Limit, http::HttpBodyStream, ok};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new();

    app.group("/api", |api| {
        // All a JSON API needs
        api.with_body_limit(Limit::Limited(64 * 1024));

        api.map_post("/messages", |Json(text): Json<String>| async move {
            ok!(text)
        });

        // The one route that takes a file
        api.map_post("/attachments", |mut stream: HttpBodyStream| async move {
            let mut total = 0;
            while let Some(chunk) = stream.next().await {
                total += chunk?.len();
            }
            ok!(format!("stored {total} bytes"))
        })
        .with_body_limit(Limit::Limited(20 * 1024 * 1024));
    });

    app.run().await
}

Which limit a request gets is decided by where it was routed:

  • The most specific limit wins. A route's own limit over its group's, a nested group's over the one around it, a group's over the application's. Where nothing sets one, the application's applies.
  • A group's limit reaches every route it registered, its nested groups' routes included, whatever the order inside the closure — like the rest of a group's configuration. A route or a nested group that set a limit of its own keeps it.
  • A group's fallback takes the group's limit. A request that no route or group claims takes the application's.
  • Limit::Default is the framework default, 5 MB — not the limit of the group or the application around it. To inherit that one, don't set a limit at all.

HttpRequest::body_limit() reports the limit the request got, in bytes, or None when it has none — in a handler or in middleware.

Lifting the Limit

without_body_limit() removes the limit — for the whole application, or, since 0.13.1, for one group or one route. Limit::Unlimited does the same. It is meant for a handler that streams the body and enforces a limit of its own, such as a proxy that passes the body through:

use volga::{App, HttpBody, HttpResponse};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new();

    // The upstream decides how much it accepts
    app.map_post("/proxy", |body: HttpBody| async move {
        HttpResponse::builder()
            .status(200)
            .body(body)
    })
    .without_body_limit();

    app.run().await
}

Warning

Without a limit the client decides how much the server reads. Lift it only on a route that counts the bytes itself, or hands them to something that does — never on one that collects the body into memory.

When the 413 Is Sent

The limit is enforced as the body is read, so a handler that never reads the body is not affected by it. When it does read it:

  • A declared length over the limit is refused before any of the body is read. If Content-Length already says the body won't fit, the first read fails with 413 — even a handler that wanted only the first few bytes gets it. A client that sent Expect: 100-continue is answered 413 instead of 100 Continue, and never uploads the body at all.
  • A body of undeclared length (chunked) is refused once it has sent more than fits.
  • A refused body fails once and then ends. The read that goes over the limit returns the 413 error, and every read after it returns the end of the body, so a loop that logs the error and reads on does not spin or pull in the rest of the upload. Return the error with ? and it reaches the error handler as any other error does, with its 413 status.

Tips

Over HTTP/1 a connection can't be reused while part of a body is left unread, so it is closed after a response like this one. While the client may still be sending, Volga keeps reading and discarding what arrives — for 2 seconds at most, and 500 ms once nothing arrives — so the client gets the 413 rather than a connection reset. A graceful shutdown waits for such a connection too. HTTP/2 ends just the stream and keeps the connection.

Building a Body

The same type constructs response bodies, which is what the ok! family of macros does under the hood:

ConstructorProduces
HttpBody::fulla complete in-memory body from anything convertible into Bytes
HttpBody::emptyan empty body
HttpBody::json / forma serialized JSON or form body
HttpBody::filea streaming body over an open file
HttpBody::stream / stream_bytesa streaming body over any Stream

Examples

You can find a raw body pass-through in the multipart example.

Last Updated: 10/20/26, 3:26 PM
Prev
Multipart Responses
Next
Cookies