HTTP
Request and response handling for Plain applications.
- Overview
- Request
- Response
- Content Security Policy (CSP)
- Middleware
- Healthcheck
- Exceptions
- FAQs
- Installation
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 aResponseto short-circuit (skip the view and remaining middleware), orNoneto continue.after_response(request, response)— runs after the view returns. Modify and return the response. This always runs for any middleware whosebefore_requestran, 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:
- ALLOWED_HOSTS rejection — the health checker uses an internal hostname not in the allowlist
- 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