eggserve-core 0.1.2

Security policy, path confinement, and static-serving primitives for eggserve
Documentation
# Migration Guide: Request Body Support

Body policy is evaluated for the actual request method. GET/HEAD/DELETE/OPTIONS
and extension-method bodies are accepted by custom services that declare
Buffer/Stream and remain bounded by the runtime ceiling. StaticService declares
Reject; TRACE content is still rejected and incomplete stream bodies close the
connection.

## Overview

Request body support is **experimental** and opt-in. The default body policy is
`reject` — all request bodies are silently dropped unless you explicitly configure
a body mode.

## Quick Start

### Before (no body support)

> The historical native callback examples below are internal implementation
> material. New Python code should use `eggserve.server` handler classes;
> advanced embedding types are under `eggserve.lowlevel`.

```python
from eggserve._native import Server

def handler(req):
    return Response.text(200, "ok")

Server(root=".", handler=handler).start()
```

### After (with body support)

```python
from eggserve._native import Server, Response

def handler(req):
    if req.has_body:
        data = req.body.read()  # or req.body.iter_chunks()
        return Response.text(200, f"Received {len(data)} bytes")
    return Response.text(200, "no body")

Server(
    root=".",
    handler=handler,
    request_body_mode="buffer",      # "reject" | "buffer" | "stream"
    max_request_body_bytes=10240,    # required for buffer/stream
    body_timeout_secs=30,            # optional, default 30
).start()
```

Incomplete streamed bodies always close the connection; there is no separate
configuration field for that behavior.

## Body Modes

| Mode | Behavior |
|------|----------|
| `reject` (default) | All bodies silently dropped; handler sees `has_body=False` |
| `buffer` | Entire body buffered in memory up to `max_request_body_bytes` |
| `stream` | Body streamed in chunks up to `max_request_body_bytes` |

## Request Body API

```python
req.has_body      # bool: True if body is present
req.body          # RequestBody or None

# Buffer mode
data = req.body.read()          # bytes: full body
data = req.body.iter_chunks()   # BodyChunkIterator: chunk-by-chunk

# Properties
req.body.declared_length   # int | None: from Content-Length header
req.body.bytes_received    # int: bytes read so far
req.body.complete          # bool: True after full consumption
```

## One-Shot Enforcement

Body objects can only be consumed once:

```python
def handler(req):
    if req.has_body:
        data = req.body.read()        # consumes the body
        # req.body.read()             # raises RequestBodyConsumedError
        # req.body.iter_chunks()      # raises RequestBodyConsumedError
```

## Error Hierarchy

```
RequestBodyError
├── RequestBodyRejectedError      # policy is reject
├── RequestBodyTooLargeError      # body exceeds max_request_body_bytes
├── RequestBodyTimeoutError       # body read timeout
├── RequestBodyDisconnectedError  # client disconnected
├── RequestBodyIncompleteError    # premature EOF
├── RequestBodyConsumedError      # body already consumed
└── RequestBodyCancelledError     # request cancelled
```

All errors inherit from `EggserveError`.

## Static Service

The built-in static service (no handler) always uses `reject` policy regardless
of constructor settings. POST/PUT/DELETE/PATCH return 405.

## Connection Behavior

- **Full consumption**: Connection kept alive (if keep-alive)
- **Partial consumption**: Connection closed (close policy)
- **Body exceeds limit**: 413 returned, connection closed
- **Body timeout**: 408 returned or connection closed

## Framing Strictness

eggserve enforces hardened HTTP/1 framing before any handler invocation:

- **TE+CL rejection**: Requests containing both `Transfer-Encoding` and any `Content-Length` field are rejected with 400 before the service is called (when both headers survive Hyper's parser normalization). Duplicate Content-Length fields are always rejected. This applies to all methods, not just body-forbidden ones.
- **Duplicate Content-Length rejection**: Requests with more than one `Content-Length` field are rejected with 400, even when values are identical. Conflicting values are rejected at the HTTP/1 wire level by Hyper.
- **Malformed Content-Length**: Non-numeric, negative, signed, overflowing, or non-decimal `Content-Length` values are rejected at the HTTP/1 wire level by Hyper before eggserve processes them.

These checks ensure no ambiguous or conflicting framing signals reach the body ingestion pipeline.

## Backward Compatibility

Existing handlers that don't inspect `req.body` continue working unchanged.
The `has_body` and `body` attributes are additions; no existing attributes
were modified.