HTTP

Request and response handling for Plain applications.

Overview

You interact with Request and Response objects in your views and middleware.

from plain.views import View
from plain.http import Response

class ExampleView(View):
    def get(self):
        # Access a request header
        user_agent = self.request.headers.get("User-Agent")

        # Access a query parameter
        page = self.request.query_params.get("page", "1")

        # Create and return a response
        response = Response("Hello, world!", status_code=200)
        response.headers["X-Custom-Header"] = "Custom Value"
        return response

Request

The Request object provides access to all incoming HTTP request data.

Headers

Access request headers through the headers property. Header names are case-insensitive.

content_type = self.request.headers.get("Content-Type")
auth = self.request.headers.get("authorization")  # Case-insensitive

Query parameters

Query string parameters are available as a QueryDict through query_params.

# URL: /search?q=plain&page=2
query = self.request.query_params.get("q")  # "plain"
page = self.request.query_params.get("page", "1")  # "2"

# For parameters with multiple values (?tags=python&tags=web)
tags = self.request.query_params.getlist("tags")  # ["python", "web"]

Body data

Access request body data based on the content type.

JSON data:

# Returns dict, raises BadRequestError400 for invalid JSON
data = self.request.json_data
name = data.get("name")

Form data:

# For application/x-www-form-urlencoded or multipart/form-data
form = self.request.form_data
email = form.get("email")

File uploads:

# For multipart/form-data requests
uploaded_file = self.request.files.get("document")
if uploaded_file:
    content = uploaded_file.read()

Raw body:

raw_bytes = self.request.body

Content negotiation

Check what content types the client accepts.

# Check if client accepts JSON
if self.request.accepts("application/json"):
    return JsonResponse({"message": "Hello"})

# Get preferred type from options
preferred = self.request.get_preferred_type("text/html", "application/json")

Cookies

Read cookies from the request.

session_id = self.request.cookies.get("session_id")

# Read a signed cookie (returns None if signature is invalid)
user_id = self.request.get_signed_cookie("user_id", default=None)

Response

The Response class creates HTTP responses with string or bytes content.

from plain.http import Response

# Basic response
response = Response("Hello, world!")

# With status code and headers
response = Response(
    content="Created!",
    status_code=201,
    headers={"X-Custom": "value"},
)

# Set content type
response = Response("<h1>Hello</h1>", content_type="text/html")

Response types

Plain provides specialized response classes for common use cases.

JSON responses:

from plain.http import JsonResponse

return JsonResponse({"name": "Plain", "version": "1.0"})

Redirects:

from plain.http import RedirectResponse

return RedirectResponse("/new-location")

File downloads:

from plain.http import FileResponse

# Serve a file
return FileResponse(open("report.pdf", "rb"))

# Force download with custom filename
return FileResponse(
    open("report.pdf", "rb"),
    as_attachment=True,
    filename="monthly-report.pdf",
)

Streaming responses:

from plain.http import StreamingResponse

def generate_data():
    for i in range(1000):
        yield f"Line {i}\n"

return StreamingResponse(generate_data(), content_type="text/plain")

Async streaming responses:

from plain.http import AsyncStreamingResponse

async def generate_data():
    for i in range(1000):
        yield f"Line {i}\n"

return AsyncStreamingResponse(generate_data(), content_type="text/plain")

AsyncStreamingResponse streams data without occupying a thread pool slot. For Server-Sent Events, use ServerSentEventsView which builds on this. You can also use AsyncStreamingResponse directly for other async streaming patterns like chunked JSON or log tailing.

Other response types include NotModifiedResponse (304) and NotAllowedResponse (405).

Access log control

Set log_access to False on a response to exclude it from the server access log.

response = Response("ok")
response.log_access = False
return response

Setting cookies

Set cookies on the response.

response = Response("Welcome!")
response.set_cookie("session_id", "abc123", httponly=True, secure=True)

# With expiration
response.set_cookie("remember_me", "yes", max_age=86400 * 30)  # 30 days

# Signed cookie (tamper-proof)
response.set_signed_cookie("user_id", "42", httponly=True)

# Delete a cookie
response.delete_cookie("old_cookie")

Default response headers

Plain applies default headers from DEFAULT_RESPONSE_HEADERS in settings to all responses. You can customize these per-view.

Override a default header:

response = Response("content")
response.headers["X-Frame-Options"] = "SAMEORIGIN"

Remove a default header:

response = Response("content")
response.headers["X-Frame-Options"] = None  # Removes the header

Extend a default header:

from plain.runtime import settings

if csp := settings.DEFAULT_RESPONSE_HEADERS.get("Content-Security-Policy"):
    csp = csp.format(request=self.request)
    response.headers["Content-Security-Policy"] = f"{csp}; script-src https://cdn.example.com"

Content Security Policy (CSP)

Plain includes built-in support for Content Security Policy through nonces. Each request generates a unique cryptographically secure nonce available via request.csp_nonce.

Configure CSP in settings:

# app/settings.py
DEFAULT_RESPONSE_HEADERS = {
    "Content-Security-Policy": (
        "default-src 'self'; "
        "script-src 'self' 'nonce-{request.csp_nonce}'; "
        "style-src 'self' 'nonce-{request.csp_nonce}'; "
        "img-src 'self' data:; "
        "font-src 'self'; "
        "connect-src 'self'; "
        "frame-ancestors 'self'; "
        "base-uri 'self'; "
        "form-action 'self'"
    ),
    "X-Frame-Options": "DENY",
}

The {request.csp_nonce} placeholder is replaced with a unique nonce for each request.

Use nonces in templates:

<script nonce="{{ request.csp_nonce }}">
    console.log("This script is allowed by CSP");
</script>

<style nonce="{{ request.csp_nonce }}">
    .example { color: red; }
</style>

External scripts and stylesheets loaded from 'self' don't need nonces:

<script src="/assets/app.js"></script>
<link rel="stylesheet" href="/assets/app.css">

Use Google's CSP Evaluator to analyze your CSP policy.

Middleware

Create custom middleware by subclassing HttpMiddleware. Middleware has two phases:

  • before_request(request) — runs before the view. Return a Response to short-circuit (skip the view and remaining middleware), or None to continue.
  • after_response(request, response) — runs after the view returns. Modify and return the response. This always runs for any middleware whose before_request ran, even if another middleware short-circuited.
from plain.http import HttpMiddleware, Request, Response

class TimingMiddleware(HttpMiddleware):
    def before_request(self, request: Request) -> Response | None:
        import time
        request.timing_start = time.time()
        return None

    def after_response(self, request: Request, response: Response) -> Response:
        import time
        duration = time.time() - request.timing_start
        response.headers["X-Request-Duration"] = f"{duration:.3f}s"
        return response

Healthcheck

The HEALTHCHECK_PATH setting provides a built-in healthcheck endpoint for load balancers, Kubernetes probes, and PaaS platforms like Railway.

# app/settings.py
HEALTHCHECK_PATH = "/up/"

When set, the server responds directly on the event loop with a 200 OK before the request reaches the thread pool or any middleware — bypassing host validation, HTTPS redirects, and authentication. This means health checks work even when the thread pool is fully saturated. It avoids two common issues with health checkers:

  1. ALLOWED_HOSTS rejection — the health checker uses an internal hostname not in the allowlist
  2. HTTPS redirect loops — the health checker sends plain HTTP without proxy headers

By default, HEALTHCHECK_PATH is empty (disabled).

Exceptions

Raise exceptions to return specific HTTP error responses.

from plain.http import NotFoundError404, ForbiddenError403, BadRequestError400

# Return 404
raise NotFoundError404("Page not found")

# Return 403
raise ForbiddenError403("Access denied")

# Return 400
raise BadRequestError400("Invalid input")

Additional exceptions include SuspiciousOperationError400, TooManyFieldsSentError400, TooManyFilesSentError400, and RequestDataTooBigError400.

FAQs

How do I access the client's IP address?

Use request.client_ip. If you're behind a proxy, enable HTTP_X_FORWARDED_FOR in settings.

ip = self.request.client_ip

How do I build an absolute URL?

Use request.build_absolute_uri().

# Current page
url = self.request.build_absolute_uri()

# Specific path
url = self.request.build_absolute_uri("/api/users")

How do I check if the request is HTTPS?

Use request.is_https() or check request.scheme.

if self.request.is_https():
    # Secure connection
    pass

What's the difference between QueryDict and a regular dict?

QueryDict handles multiple values for the same key (common in query strings and form data). Use get() for a single value or getlist() for all values.

How do I handle large file uploads?

Configure DATA_UPLOAD_MAX_MEMORY_SIZE in settings. For very large files, consider streaming the upload instead of loading it into memory.

Installation

The plain.http module is included with Plain by default. No additional installation is required.

from plain.http import Request, Response, JsonResponse