Chapter 00 — All articles
Architecture13 min read

Inside Lcore: The Architecture of a Single-File Framework

One request, end to end, through a single-file Python framework, with the design decisions behind each layer.

How Lcore works inside: following one request through the context, the router's combined regular expressions, two-phase middleware, plugins, dependency injection, revocable sessions that fail closed, and the trade-offs documented along the way.

In Why I Built Lcore I wrote about why I made my own Python web framework. This article is the other half: how it actually works inside. Everything here lives in a single file of standard-library Python, so every design decision had to justify its place. I will follow one request from the moment it arrives to the moment it leaves, and stop at each layer to explain the choices behind it.

The big picture

An Lcore app is a standard WSGI application: the Lcore object is a callable that any WSGI server, from the built-in development server to Gunicorn, can hand requests to. Inside, a request passes through a small number of clearly separated layers.

Layer

Job

Request context

Binds the current request, response and a per-request context to the worker thread.

Pre-routing middleware

Runs before any route is known, and can answer the request early.

Router

Turns a method and a path into a route and its arguments.

Lifecycle hooks

Twelve named points where application code can step in.

Post-routing middleware

Wraps the matched handler, for logging, headers, compression and similar.

Plugins

Wrap individual route callbacks, for example to turn a returned dict into JSON.

Dependency container

Provides singletons, per-request objects and fresh instances to handlers.

One request, end to end

flowchart TD
  A[WSGI server calls the app] --> B[Bind request, response and context to this thread]
  B --> C[on_request_start and before_request hooks]
  C --> D{Pre-routing middleware answered?}
  D -->|Yes, e.g. CORS preflight| R[Build response]
  D -->|No| E[Router: method + path to route]
  E -->|No match| N[404, or 405 with an Allow header]
  E --> F[on_auth_resolved and on_handler_enter hooks]
  F --> G[Post-routing middleware, plugins, then the handler]
  G --> H[on_handler_exit and on_response_build hooks]
  H --> R
  R --> S[on_response_send hook]
  S --> T[Response returned to the server]

Two details in that flow matter more than they look. First, the pre-routing phase can short-circuit: a CORS preflight request gets its answer without the router ever running. Second, scoped dependencies are opened when the request begins and closed when it ends, even when the handler raises, so a database session can never leak from a failed request.

The request context

Handlers in Lcore read request, write to response and share state through ctx, all as module-level objects. That only works because each one is bound to the current thread. When a request starts, Lcore binds the incoming WSGI environment to the thread-local request, a fresh response, and a RequestContext that holds the app, the matched route, a request id, the user, a free-form state dictionary and lazily computed values.

The context class uses __slots__. It is created for every single request, so fixing its attributes keeps it small and fast, and it also catches typos: assigning an attribute that does not exist is an error instead of a silent new field.

The router

The router is where a framework spends time on every request, so it is designed around one idea: do as little work as possible for the common case.

Static routes: a dictionary lookup

A route with no parameters, such as /health, is stored in a plain dictionary keyed by HTTP method and then by path. Matching it is a single hash lookup, no matter how many routes the app has.

Dynamic routes: one combined regular expression

Routes with parameters, such as /users/<id:int>, are turned into regular expressions using their filters. Instead of trying each pattern one after another, Lcore joins the patterns for each method into a single combined expression, each wrapped in its own anchored group, joined with alternation:

(^/users/(?:\d+)$)|(^/posts/(?:[^/]+)$)|(^/files/(?:.+)$)

Before joining them, the router flattens each pattern so its parameter groups no longer capture. That leaves exactly one numbered group per route, so after a single call to the regex engine, the match object's lastindex says which route matched. Only then does that one route's own pattern run to pull out the named arguments and apply their filters. Routes are combined in chunks of up to 99 per pattern, which keeps every expression to a bounded size. This approach comes from Bottle's router, the framework that inspired Lcore, and it is a big part of why routing stays fast as an app grows.

Doing the expensive work only on a miss

A few small rules keep the hot path cheap:

  • A HEAD request falls back to the matching GET route, so you never register both.
  • Only when nothing matches does the router check other HTTP methods. If the path exists under a different method, it answers 405 with an accurate Allow header. Otherwise it answers 404. Successful requests never pay for that check.
  • Parameter filters convert values after a match: <id:int> arrives in the handler as an integer, a value that fails conversion answers 400, and custom filters can be registered for formats like UUIDs.
flowchart TD
  A["Request: method and path"] --> B{"Static route? One dict lookup"}
  B -->|Hit| R["Route and arguments"]
  B -->|Miss| C["Combined regex for this method"]
  C -->|Match| D["lastindex picks the route"]
  D --> E["That route's own pattern extracts the arguments, filters convert them"]
  E --> R
  C -->|No match| F{"Path exists under another method?"}
  F -->|Yes| G["405 with an Allow header"]
  F -->|No| H["404"]

Middleware in two phases

Every middleware receives the request context and a next_handler to call, so it can inspect, change or short-circuit the request. What makes Lcore's middleware different is that each one declares a phase.

  • Pre-routing middleware runs before the router. It suits work that does not depend on which route matches: answering CORS preflights, enforcing body size limits, checking CSRF tokens and fixing client addresses behind a proxy.
  • Post-routing middleware wraps the matched handler. It suits work that needs the route or the response: request IDs, structured logging, security headers, compression, sessions and timeouts.

Within each phase, a numeric order decides who runs first. The built-in middleware ship with sensible positions:

Middleware

Phase

Order

Proxy fix

pre

-10

Body limit

pre

0

CORS

pre

3

CSRF

pre

10

Timeout

post

-5

Request ID

post

1

Request logger

post

2

Security headers

post

5

Sessions

post

20

Compression

post

90

The ordering is deliberate. Middleware nest like layers of an onion: the lowest order is the outermost layer, entered first on the way in and left last on the way out. The proxy fix runs first of all, because every later layer that reads the client's address needs the real one. Among the post-routing middleware, the timeout sits outermost so its deadline covers everything inside it, and compression sits closest to the handler, so it compresses the handler's output first and the outer layers only add headers and logging around an already compressed response.

Chains are also cheap to build. When no middleware is limited to particular routes, Lcore builds the pre and post chains once and reuses them for every request. When some are scoped to route patterns, the chains for each path are kept in a small cache of the 1,024 most recent paths.

flowchart TD
  RQ["Request"] --> PF["Proxy fix, order -10"]
  PF --> BL["Body limit, 0"] --> CO["CORS, 3"] --> CS["CSRF, 10"]
  CS -->|No early answer| RT["Router"]
  RT --> TO["Timeout, -5, outermost post layer"]
  TO --> RI["Request ID, 1"] --> LG["Logger, 2"] --> SH["Security headers, 5"] --> SE["Sessions, 20"] --> GZ["Compression, 90, closest to the handler"]
  GZ --> HD["Handler"]
  HD -.->|Response travels back out through the same layers| TO

Plugins versus middleware

Lcore has both, and they solve different problems.

Plugins

Middleware

Wraps

One route's callback

The whole request pipeline

Receives

The callback and its route

The request context and the next handler

Per-route settings

Yes, through the route's config

Only through route patterns

Typical use

JSON output, templates, database sessions

CORS, auth, logging, rate limiting

A plugin is applied once per route: the first time a route is called, Lcore wraps its callback with every installed plugin and caches the result on the route. That is why the JSON plugin, installed by default, costs almost nothing at request time: the wrapping already happened. It is also why a single route can opt out with a setting such as json_disable=True, something middleware cannot express cleanly.

sequenceDiagram
  participant App
  participant Route
  participant Plugins
  App->>Route: First request for this route
  Route->>Plugins: Wrap the callback with each installed plugin
  Plugins-->>Route: Wrapped callback
  Note over Route: Cached on the route
  App->>Route: Every later request
  Route-->>App: Reuse the cached callback, no wrapping cost

Dependency injection

Handlers often need shared resources: a cache client, a database session, a logger. Lcore registers each one with a lifetime and exposes it on ctx.

Lifetime

Behavior

Good for

Singleton

One instance for the life of the app, created safely under a lock

Cache clients, configuration

Scoped

One instance per request, closed automatically at the end

Database sessions

Transient

A new instance on every access

Unique IDs, short-lived helpers

Two small guards make this safe. Registering a dependency under a name the context already uses, such as request or user, is rejected, because the built-in attribute would silently win. And a scoped dependency with a close() method is closed when the request ends, whether it succeeded or failed.

flowchart LR
  subgraph APP["For the life of the app"]
    S1["Singleton: created once under a lock, then shared"]
  end
  subgraph REQ["For each request"]
    B["Scope begins"] --> SC["Scoped: one instance for this request"]
    SC --> E["Scope ends: close() runs, even after an error"]
  end
  T["Transient: a new instance on every access"]

Sessions: designed for revocation

Signed cookies are fine for a few bytes of state, but they have one fatal flaw for logins: once issued, they cannot be taken back. So Lcore's sessions keep the data on the server and put only a signed session id in the cookie. That makes every session revocable.

Cheap for anonymous visitors

A session is loaded lazily, only when code touches it, and written back only when it changes. Anonymous browsing therefore causes no storage writes and sets no cookie. Even the common pattern of popping a one-time message is free when there is nothing to pop, because only real changes mark a session as dirty.

flowchart TD
  A["Request arrives"] --> B{"Does code touch the session?"}
  B -->|No| Z["No backend read, no cookie set"]
  B -->|Yes| C["Load the record by its signed id"]
  C --> D{"Is the user's epoch still current?"}
  D -->|No| X["Treated as signed out"]
  D -->|Yes| E{"Changed during the request?"}
  E -->|No| F["No write"]
  E -->|Yes| G["Write back and re-sign the cookie"]

Two ways to revoke

Every session backend implements two independent mechanisms:

  • An index, a per-user set of live session ids. It powers an "active devices" page and signing out one device without the others.
  • An epoch, a per-user counter stamped into each session when it is bound to a user. Increase it, and every session issued earlier dies on its next load, without walking the index.
sequenceDiagram
  participant U as User
  participant A as App
  participant B as Session backend
  U->>A: Reset password
  A->>B: Delete every session in the user's index
  A->>B: Increase the user's epoch
  Note over B: Sessions the index missed now carry an old epoch
  U->>A: Request with an old session
  A->>B: Load session
  B-->>A: Epoch is stale
  A-->>U: Treated as signed out

A password reset uses both. The index removes every session the backend knows about, and the epoch catches any it does not, such as one written by another worker while the reset was happening. Logging in also regenerates the session id, which closes session fixation, where an attacker plants a known id before login and keeps a valid session afterwards.

Checking the epoch on every request would cost a second storage read, so each process caches it for a few seconds. That staleness is safe: the index has already removed sessions immediately, so the epoch is only the backstop, and a change made in the same process clears its cache at once.

Fail closed, not open

One of the most important decisions in Lcore is what happens when storage is unavailable. Rate limiting fails open: if its store is down, requests are allowed, because the worst case is that limits stop applying for a while. Sessions fail closed by default and answer 503 instead, because ignoring the store would mean ignoring revocation, and silently signing a customer out in the middle of a checkout loses their cart. A login that cannot be stored never sets a cookie and never reports success. Applications that prefer to stay up can choose to treat requests as anonymous during an outage, but the safe behavior is the default.

flowchart LR
  A["Backing store is unreachable"] --> B{"Which component?"}
  B -->|Rate limiter| C["Fail open: allow the request and log a warning"]
  B -->|Sessions, default| D["Fail closed: 503, no cookie, no false success"]
  B -->|Sessions, anonymous mode| E["Treat the visitor as signed out"]

Streaming without losing memory

Compression shows the same thinking at a smaller scale. A normal response is compressed in one step and keeps its Content-Length. A streamed response is buffered up to a threshold, and only if it outgrows that threshold does Lcore switch to incremental compression and send it in chunks. Before this change, a 20 MB streamed body was buffered in full, costing roughly 40 MB per concurrent request and defeating the point of streaming. Now memory stays flat, at the honest cost of dropping the Content-Length header for very large streams.

flowchart TD
  A["Response body"] --> B{"A plain string or bytes?"}
  B -->|Yes| C["Compress in one call, keep Content-Length"]
  B -->|No, an iterable| D["Buffer up to the stream threshold"]
  D --> E{"Did it fit?"}
  E -->|Yes| C
  E -->|No| F["Switch to incremental gzip, send in chunks, drop Content-Length"]

Trade-offs I chose to document

A framework is a set of trade-offs, and the honest thing is to write them down.

  • Timeouts. The timeout middleware runs the handler in a thread pool. Python cannot kill a stuck thread, so on timeout the client gets a 503 while the thread finishes in the background, and the hop costs about 30 microseconds per request against about 9 for a bare route. The documentation recommends Gunicorn's and the reverse proxy's timeouts first, and this middleware only for tight per-route limits.
  • Sessions. Installing session middleware costs roughly 25 to 30 percent on every request, even on routes that never touch a session. That is the price of it always being available, and the documentation says so.
  • Async. Lcore is WSGI. Async handlers are accepted but block their thread, and Lcore warns the first time one runs. Real concurrency for slow I/O comes from the gevent and eventlet server adapters instead, with no code changes.

What the architecture taught me

  • Optimize the path every request takes. A dictionary for static routes and one combined regex for dynamic ones did more for speed than any micro-tuning.
  • Separate concerns by when they need to run. Splitting middleware into pre-routing and post-routing phases made each one simpler and let some requests finish before routing at all.
  • Design the failure modes on purpose. Deciding that sessions fail closed and rate limits fail open was more important than any feature.
  • Write the costs down. Numbers like 30 microseconds or a 25 percent overhead are what let people make informed decisions about a framework.

The full documentation, including every middleware, the session backends and the API reference, is at lcore.lusansapkota.com.np, and the source code is on GitHub at github.com/Lusan-sapkota/lcore.

TaggedPythonLcoreWSGIArchitectureRoutingMiddlewareSessionsWeb Frameworks
7 viewsUpdated