Content Negotiation
Section titled “Content Negotiation”A comprehensive content negotiation middleware for sillo, shipped as the first-party sillo.http.accepts module. It parses Accept, Accept-Language, Accept-Charset, and Accept-Encoding headers and performs RFC 7231 content negotiation.
It automatically:
- Parses and processes
Accept,Accept-Language,Accept-Charset, andAccept-Encodingheaders - Performs intelligent content negotiation based on client preferences
- Sets appropriate
Varyheaders for proper caching - Provides utilities for manual content negotiation
- Supports strict negotiation with
406 Not Acceptableresponses
Quick Start
Section titled “Quick Start”from sillo import silloAppfrom sillo.http.accepts import Accepts
app = silloApp()
app.use(Accepts())
@app.get("/")async def home(request, response): accepts_info = getattr(request.state, "accepts", {}) return { "message": "Hello with Content Negotiation!", "accepts": accepts_info.get("raw_accept", ""), }Content Negotiation Examples
Section titled “Content Negotiation Examples”Basic Usage
Section titled “Basic Usage”from sillo import silloAppfrom sillo.http.accepts import Accepts
app = silloApp()app.use(Accepts())
@app.get("/api/data")async def get_data(request, response): data = {"message": "API data", "items": [1, 2, 3]} # Client sends: Accept: application/json # Response will be: Content-Type: application/json return dataManual Content Negotiation
Section titled “Manual Content Negotiation”from sillo import silloAppfrom sillo.http.accepts import ContentNegotiationMiddleware, negotiate_content_type
app = silloApp()app.use(ContentNegotiationMiddleware())
@app.get("/api/content")async def get_content(request, response): available_types = ["application/json", "application/xml", "text/html"] best_type = negotiate_content_type( request.headers.get("Accept", ""), available_types, ) response.headers["Content-Type"] = best_type if best_type == "application/json": return {"data": "JSON format"} elif best_type == "application/xml": return "<data>XML format</data>" else: return "<h1>HTML format</h1>"Language Negotiation
Section titled “Language Negotiation”from sillo import silloAppfrom sillo.http.accepts import ContentNegotiationMiddleware, negotiate_language
app = silloApp()app.use(ContentNegotiationMiddleware())
@app.get("/greetings")async def get_greetings(request, response): available_languages = ["en", "es", "fr", "de"] best_language = negotiate_language( request.headers.get("Accept-Language", ""), available_languages, ) greetings = {"en": "Hello", "es": "Hola", "fr": "Bonjour", "de": "Guten Tag"} return { "greeting": greetings.get(best_language, greetings["en"]), "language": best_language, }Strict Content Negotiation
Section titled “Strict Content Negotiation”from sillo import silloAppfrom sillo.http.accepts import StrictContentNegotiationMiddleware
app = silloApp()app.use( StrictContentNegotiationMiddleware( available_types=["application/json", "application/xml"], available_languages=["en", "es"], ))
@app.get("/api/strict")async def strict_api(request, response): # Returns 406 Not Acceptable if the client doesn't accept JSON/XML # or doesn't accept English/Spanish negotiated_type = getattr(request, "negotiated_content_type", "application/json") negotiated_lang = getattr(request, "negotiated_language", "en") return { "data": "Strict API response", "content_type": negotiated_type, "language": negotiated_lang, }Configuration Options
Section titled “Configuration Options”AcceptsMiddleware Options
Section titled “AcceptsMiddleware Options”| Option | Type | Default | Description |
|---|---|---|---|
default_content_type | str | "application/json" | Default content type when no match found |
default_language | str | "en" | Default language when no match found |
default_charset | str | "utf-8" | Default charset when no match found |
set_vary_header | bool | True | Automatically set Vary headers |
store_accepts_info | bool | True | Store parsed accepts info in request object |
StrictContentNegotiationMiddleware Options
Section titled “StrictContentNegotiationMiddleware Options”| Option | Type | Default | Description |
|---|---|---|---|
available_types | List[str] | Required | Content types this application can serve |
available_languages | List[str] | ["en"] | Languages this application supports |
Usage Examples
Section titled “Usage Examples”Basic Setup
Section titled “Basic Setup”from sillo import silloAppfrom sillo.http.accepts import Accepts
app = silloApp()app.use(Accepts())Custom Configuration
Section titled “Custom Configuration”from sillo import silloAppfrom sillo.http.accepts import Accepts
app = silloApp()app.use( Accepts( default_content_type="application/json", default_language="en", default_charset="utf-8", set_vary_header=True, store_accepts_info=True, ))Accessing Accept Information
Section titled “Accessing Accept Information”@app.get("/debug/accepts")async def debug_accepts(request, response): accepts = getattr(request, "accepts", {}) return { "accept_header": accepts.get("raw_accept", ""), "accepted_types": [item.value for item in accepts.get("accept", [])], "accept_language": accepts.get("raw_accept_language", ""), "accepted_languages": [item.value for item in accepts.get("accept_language", [])], "accept_charset": accepts.get("raw_accept_charset", ""), "accept_encoding": accepts.get("raw_accept_encoding", ""), }Content Negotiation Utilities
Section titled “Content Negotiation Utilities”from sillo.http.accepts import get_best_match, negotiate_content_type
@app.get("/negotiate")async def negotiate_example(request, response): best_type = get_best_match( request.headers.get("Accept", ""), ["application/json", "text/html", "application/xml"], ) json_type = negotiate_content_type( request.headers.get("Accept", ""), ["application/json", "application/vnd.api+json"], ) return { "best_match": best_type, "json_match": json_type, "accept_header": request.headers.get("Accept", ""), }Supported Headers
Section titled “Supported Headers”Input Headers (Parsed)
Section titled “Input Headers (Parsed)”Accept: Media types (e.g.,text/html, application/json;q=0.9)Accept-Language: Languages (e.g.,en-US, en;q=0.9, es;q=0.8)Accept-Charset: Character sets (e.g.,utf-8, iso-8859-1;q=0.9)Accept-Encoding: Content encodings (e.g.,gzip, deflate, br)
Output Headers (Set)
Section titled “Output Headers (Set)”Vary: Automatically set to include relevant Accept headersContent-Type: Automatically negotiated based on Accept header
Content Negotiation Algorithm
Section titled “Content Negotiation Algorithm”Media Type Matching
Section titled “Media Type Matching”Follows RFC 7231 content negotiation rules:
- Exact Match:
application/jsonmatchesapplication/json - Type Match:
text/*matchestext/html,text/plain - Wildcard Match:
*/*matches any media type - Quality Factor: Items are sorted by q-value (0.0 to 1.0)
- Specificity: More specific types are preferred over generic ones
Language Matching
Section titled “Language Matching”- Exact Match:
en-USmatchesen-US - Prefix Match:
enmatchesen-US,en-GB - Fallback: First available language if no match
Helper Functions
Section titled “Helper Functions”Parsing Accept Headers
Section titled “Parsing Accept Headers”from sillo.http.accepts import parse_accept_header, parse_accept_language
accept_items = parse_accept_header("text/html, application/json;q=0.9")# [AcceptItem("text/html", 1.0), AcceptItem("application/json", 0.9)]
lang_items = parse_accept_language("en-US, en;q=0.9, es;q=0.8")# [AcceptItem("en-US", 1.0), AcceptItem("en", 0.9), AcceptItem("es", 0.8)]Content Negotiation
Section titled “Content Negotiation”from sillo.http.accepts import negotiate_content_type, negotiate_language
best_type = negotiate_content_type( "text/html, application/json;q=0.9", ["application/json", "text/html", "application/xml"],)# "text/html" (exact match wins over quality)
best_lang = negotiate_language("en-US, fr;q=0.8", ["en", "fr", "es"])# "en" (prefix match)Best Match Selection
Section titled “Best Match Selection”from sillo.http.accepts import get_best_match
best = get_best_match( "application/json, text/html;q=0.9", ["text/html", "application/json", "application/xml"],)# "application/json"Advanced Usage
Section titled “Advanced Usage”Custom Content Negotiation
Section titled “Custom Content Negotiation”from sillo import silloAppfrom sillo.http.accepts import ContentNegotiationMiddleware
class CustomNegotiationMiddleware(ContentNegotiationMiddleware): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.custom_types = { "application/vnd.api.v1+json": ["application/json"], "application/vnd.api.v2+json": ["application/json", "application/vnd.api.v1+json"], }
def negotiate_content_type(self, request, available_types, default_type=None): accept_header = request.headers.get("Accept", "") for custom_type, fallback_types in self.custom_types.items(): if custom_type in accept_header: for fallback in fallback_types: if fallback in available_types: return fallback return super().negotiate_content_type(request, available_types, default_type)
app = silloApp()app.use(CustomNegotiationMiddleware())API Versioning with Content Negotiation
Section titled “API Versioning with Content Negotiation”from sillo import silloAppfrom sillo.http.accepts import ContentNegotiationMiddleware
app = silloApp()app.use(ContentNegotiationMiddleware())
@app.get("/api/users")async def get_users(request, response): accept_header = request.headers.get("Accept", "") if "application/vnd.myapi.v2+json" in accept_header: response.headers["Content-Type"] = "application/vnd.myapi.v2+json" return {"users": [], "version": "v2", "features": ["new_field"]} else: response.headers["Content-Type"] = "application/vnd.myapi.v1+json" return {"users": [], "version": "v1"}Internationalization (i18n)
Section titled “Internationalization (i18n)”from sillo import silloAppfrom sillo.http.accepts import ContentNegotiationMiddleware, negotiate_language
app = silloApp()app.use(ContentNegotiationMiddleware())
@app.get("/messages")async def get_messages(request, response): available_messages = { "en": {"greeting": "Hello", "farewell": "Goodbye"}, "es": {"greeting": "Hola", "farewell": "Adiós"}, "fr": {"greeting": "Bonjour", "farewell": "Au revoir"}, } best_lang = negotiate_language( request.headers.get("Accept-Language", ""), list(available_messages.keys()), ) messages = available_messages.get(best_lang, available_messages["en"]) response.headers["Content-Language"] = best_lang return messagesHow negotiation resolves a type
Section titled “How negotiation resolves a type”negotiate_content_type(header, available) walks the client’s Accept items in order and returns the first available type that matches:
- Items with
q=0are skipped — the client is explicitly rejecting that type. - An exact match (
application/json==application/json) wins immediately. - A range match:
text/*matchestext/html;*/*matches anything. - If nothing matches, the function returns
Noneand the middleware falls back todefault_content_type.
Quality values (q) do not override specificity: text/html, application/json;q=0.9 still yields text/html on an exact match, because the first matching item in client order is returned before quality is consulted for ordering. matches_media_type is the single source of truth for the match rules above.
Errors and edge cases
Section titled “Errors and edge cases”- No
Acceptheader —negotiate_content_typereturns the first available type (ordefault_content_typein the middleware). Clients that omitAcceptget your default format. - No acceptable match — with
Accepts/ContentNegotiationMiddlewarethe response usesdefault_content_type; withStrictContentNegotiationMiddlewarethe request is short-circuited with406 Not Acceptableand a JSON body listingavailable_types. The 406 is returned before your handler runs. q=0rejection —text/html;q=0is never selected even iftext/htmlis available. Use this to test negotiation without sending an unmatched header.*/*wildcard — matches any available type, so an API client sendingAccept: */*receives your default format, not necessarily the most specific one.Varyheader —AcceptsappendsAccept,Accept-Language,Accept-Charset,Accept-Encoding(only those present on the request) toVary. Reverse proxies and CDNs use this to cache per-negotiated variant; disabling it (set_vary_header=False) means negotiated responses can be served to the wrong client.
Testing
Section titled “Testing”Drive negotiation through TestClient by setting request headers. Assert both the chosen format and the Content-Type/Vary response headers.
from sillo import silloAppfrom sillo.http.accepts import Acceptsfrom sillo.testclient import TestClient
def test_negotiates_json(): app = silloApp() app.use(Accepts())
@app.get("/data") async def data(request, response): return {"ok": True}
client = TestClient(app) resp = client.get("/data", headers={"Accept": "application/json"}) assert resp.status_code == 200 assert resp.headers["Content-Type"].startswith("application/json")
def test_strict_returns_406(): from sillo.http.accepts import StrictContentNegotiationMiddleware
app = silloApp() app.use( StrictContentNegotiationMiddleware(available_types=["application/json"]) )
@app.get("/data") async def data(request, response): return {"ok": True}
client = TestClient(app) resp = client.get("/data", headers={"Accept": "text/html"}) assert resp.status_code == 406For handlers that read request.state.accepts, assert the parsed structure rather than the raw header:
def test_stores_accepts_info(): app = silloApp() app.use(Accepts())
@app.get("/debug") async def debug(request, response): return {"types": [i.value for i in request.state.accepts["accept"]]}
resp = TestClient(app).get( "/debug", headers={"Accept": "text/html, application/json;q=0.9"} ) assert resp.json()["types"] == ["text/html", "application/json"]Production considerations
Section titled “Production considerations”- Caching — keep
set_vary_header=Trueso CDNs and browser caches key on the negotiated headers. Returning JSON to a client that asked for HTML (or vice versa) is a classic misconfiguration caused by a missingVary. - Public API boundaries — use
StrictContentNegotiationMiddlewarewhen your API only serves a fixed set of types; the406makes unsupportedAcceptvalues explicit instead of silently downgrading. - Defaults are responses too — when a client sends no
Accept, thedefault_content_typeis what they get. Make it the format most clients expect. - Ordering of middleware —
AcceptssetsContent-Typeonly when the response has none, duringprocess_response. Place it where downstream middleware won’t overwriteContent-Typeafterward. - Language vs content type —
negotiate_languageonly computes a language; you must setContent-Languageand select localized content yourself, as shown in the i18n example above.
Best Practices
Section titled “Best Practices”- Always set Vary headers when using content negotiation for proper caching
- Provide sensible defaults for content type and language
- Test with real browsers — different browsers send different Accept headers
- Use strict negotiation for APIs that only support specific content types
- Cache negotiated responses — use Vary headers to ensure proper cache keys
- Support multiple formats for better API compatibility
Example Production Configuration
Section titled “Example Production Configuration”from sillo import silloAppfrom sillo.http.accepts import StrictContentNegotiationMiddleware
app = silloApp()app.use( StrictContentNegotiationMiddleware( available_types=[ "application/json", "application/vnd.api.v1+json", "application/vnd.api.v2+json", ], available_languages=["en", "es", "fr", "de"], default_content_type="application/json", default_language="en", ))
@app.useasync def content_negotiation_logger(request, response, call_next): negotiated_type = getattr(request, "negotiated_content_type", "unknown") negotiated_lang = getattr(request, "negotiated_language", "unknown") print(f"Content-Type: {negotiated_type}, Language: {negotiated_lang}") return await call_next()Troubleshooting
Section titled “Troubleshooting”Content-Type Not Being Set
Section titled “Content-Type Not Being Set”- Check that Accepts middleware is added to your app
- Verify the Accept header is being sent by the client
- Ensure
default_content_typeis set correctly
Vary Headers Too Broad
Section titled “Vary Headers Too Broad”- Set
set_vary_header=Falseif you don’t need automatic Vary headers - Manually set specific Vary headers as needed
- Consider caching implications when using content negotiation
Language Fallback Issues
Section titled “Language Fallback Issues”- Verify Accept-Language header is being sent
- Check that your available languages list is correct
- Ensure proper language codes (e.g.,
"en-US", not"en_US")
Debug Accept Headers
Section titled “Debug Accept Headers”@app.get("/debug/headers")async def debug_headers(request, response): return { "accept": request.headers.get("Accept"), "accept_language": request.headers.get("Accept-Language"), "accept_charset": request.headers.get("Accept-Charset"), "accept_encoding": request.headers.get("Accept-Encoding"), "user_agent": request.headers.get("User-Agent"), }Built with ❤️ by the @sillohq community.
Negotiation is a contract, not a guess
Section titled “Negotiation is a contract, not a guess”Content negotiation lets one URL serve several representations, and the failure mode is subtle: a client that gets a format it did not expect usually does not error, it misparses.
Three rules keep it predictable.
Default deterministically. When a client sends no Accept header, or
sends */*, pick one format and always pick the same one. JSON is the
right default for an API. A default that varies by route makes clients
defensive everywhere.
Reject what you cannot produce. A client asking for
application/xml from an endpoint that only speaks JSON should get a
406 Not Acceptable, not JSON with an XML content type and not a silent
fallback. Silence here means the client ships a bug you could have
surfaced.
Vary on what you negotiate. Any response whose body depends on a
request header must carry Vary naming that header. Without it, a shared
cache stores the first representation and serves it to everyone —
including the client that asked for the other one.
response.headers["Vary"] = "Accept, Accept-Encoding"This is the single most commonly omitted header in negotiated APIs, and the bug it causes appears only once a CDN is in front of you.