Claude Fable 5.1 & GPT-6 Astra packages are live

Flask

Free

Flask application structure — the app factory, blueprints by feature, config classes, extension initialisation, request lifecycle hooks, error…

247 lines8.8 KB Gemini Backend
targetModels
Gemini 3.8 FlashGemini 3.7 FlashGemini 3.1 ProGemini 3 FamilyFuture Gemini Models
name
flask
category
Backend
description
Flask application structure — the app factory, blueprints by feature, config classes, extension initialisation, request lifecycle hooks, error handlers, and when to reach for FastAPI instead.
license
MIT
author
Agent.md maintainers
last-verified
reviewed-by
unreviewed
<!-- Generated from models/_canonical by scripts/build-model-variants.js. Edit the canonical source, not this file. Behavioural profile for Gemini: scripts/model-profiles.json -->

#Purpose

Rules for a Flask service that is testable and does not collapse into one app.py. Flask is a microframework: it decides nothing for you, so every structural decision below is one it left open.

Python conventions are Backend/python-conventions; if the service is mostly JSON with typed models, read the last section before choosing Flask.


#The app factory

python
# app/__init__.py
def create_app(config: type[Config] = ProdConfig) -> Flask:
    app = Flask(__name__)
    app.config.from_object(config)
    db.init_app(app)                       # extensions are module-level, bound here
    app.register_blueprint(orders_bp, url_prefix="/v1/orders")
    register_error_handlers(app)
    return app

# wsgi.py — the only place an app is instantiated for serving
app = create_app()
  • No app = Flask(__name__) at module level anywhere else. A global app is configured at import time, cannot be built twice with different config, and makes every test share state.
  • Extensions (SQLAlchemy(), Migrate(), LoginManager()) are created without an app and bound in the factory with init_app.
  • Tests call create_app(TestConfig).

#Blueprints by feature

scss
app/
  orders/
    __init__.py     # bp = Blueprint("orders", __name__)
    routes.py       # HTTP only
    services.py     # rules; no `request` imported here
    schemas.py      # marshmallow / pydantic
  extensions.py     # db = SQLAlchemy(); migrate = Migrate()
python
@bp.post("")
def create_order():
    data = OrderSchema().load(request.get_json(force=False, silent=False))
    order = services.create_order(actor=current_user, **data)
    return OrderSchema().dump(order), 201

The rule: request and g do not leave routes.py. A service that reads request.json cannot run from a CLI command or a worker. Pass values in.


#Config

python
class Config:
    SECRET_KEY = os.environ["SECRET_KEY"]                   # fail fast if missing
    SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
    MAX_CONTENT_LENGTH = 1 * 1024 * 1024                   # 1 MiB request cap
    SESSION_COOKIE_SECURE = True
    SESSION_COOKIE_HTTPONLY = True
    SESSION_COOKIE_SAMESITE = "Lax"

class TestConfig(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = "sqlite://"
  • os.environ["X"] not os.environ.get("X") for required values; a KeyError at boot beats a None secret key in production.
  • MAX_CONTENT_LENGTH is unset by default — set it, or a single request can exhaust memory.
  • Never app.config["DEBUG"] = True in a config that could reach production; the Werkzeug debugger is remote code execution.

#Request lifecycle

python
@app.before_request
def attach_request_id():
    g.request_id = request.headers.get("X-Request-Id") or uuid4().hex

@app.teardown_appcontext
def shutdown_session(exc):
    db.session.remove()            # return the connection; roll back on error
  • g is per-request; use it for the request id, the current tenant, timing. Module globals for the same purpose leak between requests under threading.
  • teardown_appcontext runs even when the view raised — put cleanup there, not in after_request, which is skipped on unhandled exceptions.
  • Behind a proxy, wrap with ProxyFix(app.wsgi_app, x_for=1, x_proto=1) with the exact hop count; otherwise request.remote_addr is the proxy and rate limits key on one address for everyone. → API/rate-limiting

#Error handlers

python
def register_error_handlers(app: Flask) -> None:
    @app.errorhandler(ValidationError)
    def bad_input(e):
        return {"error": "validation", "fields": e.messages}, 400

    @app.errorhandler(HTTPException)
    def http_error(e):
        return {"error": e.name.lower().replace(" ", "_")}, e.code

    @app.errorhandler(Exception)
    def unhandled(e):
        app.logger.exception("unhandled", extra={"request_id": g.get("request_id")})
        return {"error": "internal"}, 500
  • Register a handler for HTTPException so abort(404) returns your JSON envelope, not Werkzeug's HTML page.
  • The catch-all logs the traceback with the request id and returns a fixed body. A default 500 in a JSON API returns HTML to the client.
  • Domain exceptions are raised in services and mapped here, once. → Backend/error-handling

#Testing

python
@pytest.fixture
def client():
    app = create_app(TestConfig)
    with app.app_context():
        db.create_all()
        yield app.test_client()
        db.drop_all()

def test_create_order_requires_items(client):
    r = client.post("/v1/orders", json={"items": []})
    assert r.status_code == 400
    assert r.get_json()["error"] == "validation"

app.test_client() drives the WSGI app in-process — no port, parallel-safe. Assert the error envelope, not just the status. → Testing/pytest


#Flask or FastAPI

Choose Flask whenChoose FastAPI when
Server-rendered HTML with Jinja and sessionsThe service is JSON-first
A large existing Flask codebase and teamYou want typed models, DI and OpenAPI built in
Sync everything, simple deployment (gunicorn)Endpoints await async drivers or fan out I/O
Extensions you already depend onYou would otherwise bolt on marshmallow + apispec

Flask 2.x can async def a view, but it runs each one in a thread via asgiref; it is not an async framework. If half your views are async, you picked the wrong one. → Backend/fastapi


#Anti-patterns

Anti-patternWhy it failsFix
Module-level app = Flask(__name__)Untestable, single configApp factory
Extensions bound at importSame problem, plus circular importsinit_app in the factory
request used inside servicesNot callable from CLI or workersPass values in
os.environ.get("SECRET_KEY")None secret in productionos.environ["..."]
No MAX_CONTENT_LENGTHMemory exhaustion by one requestSet a cap
debug=True reachable in prodRemote code execution via debuggerNever in a prod config
Module globals for request stateCross-request leakageg
Cleanup in after_requestSkipped on exceptionsteardown_appcontext
No HTTPException handlerHTML error pages from a JSON APIRegister one
Bare except returning 200Errors disappearHandler that logs and returns 500
No ProxyFix behind a proxyWrong client IP everywhereProxyFix with hop count
Fifty routes in app.pyUnnavigableBlueprints by feature
async def views everywhereThread-per-view, not asyncFastAPI

#Checklist

  • Verify: create_app(config) factory; no module-level app
  • Verify: Extensions created bare and bound with init_app
  • Verify: One blueprint per feature with routes.py/services.py split
  • Verify: request and g never imported by services
  • Verify: Required config read with os.environ["X"]
  • Verify: MAX_CONTENT_LENGTH and secure cookie flags set
  • Verify: Debug mode impossible in production config
  • Verify: Request id attached in before_request and logged
  • Verify: Session cleanup in teardown_appcontext
  • Verify: ProxyFix applied with the exact proxy count
  • Verify: Handlers registered for ValidationError, HTTPException, Exception
  • Verify: Tests use create_app(TestConfig) and test_client()
  • Verify: Framework choice justified against the Flask/FastAPI table

#Anchors (restated last, read last)

The rules that must hold when you stop, repeated here because the end of the context is what you act on:

  • create_app(config) factory; no module-level app
  • Extensions created bare and bound with init_app
  • One blueprint per feature with routes.py/services.py split
  • request and g never imported by services
  • Required config read with os.environ["X"]
  • MAX_CONTENT_LENGTH and secure cookie flags set

Before reporting done, prove the module still imports — run the line for this stack and paste its output:

bash
python -c "import <package>"          # Python: the package you changed
node -e "require('./<entry>')"       # Node CJS, or: node --input-type=module -e "import './<entry>.js'"
go build ./...                        # Go