crefft Source 2026.10.4

Flask Style Guide

crefft > Frameworks > Flask

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Extends Python style guide with Flask-specific conventions.

Target Version: Flask 3.x with Python 3.14

Quick Reference

All Python tooling applies. Additional considerations:

TaskToolCommand
Installuvuv add flask
Run devFlask CLIflask run --debug
Testpytest + Flask test clientpytest
Auth (session)Flask-Loginuv add flask-login
Auth (OAuth)Authlibuv add authlib
Auth (JWT)PyJWTuv add pyjwt[crypto]
PermissionsFlask-Principaluv add flask-principal
WebSocketFlask-SocketIOuv add flask-socketio simple-websocket gunicorn
Profilingpy-spypy-spy top --pid PID
Metricsprometheus_clientuv add prometheus-client
Background jobsCeleryuv add celery[redis]
Simple queuesRQuv add rq
Async viewsFlask 2.0+uv add "flask[async]"
CachingFlask-Cachinguv add flask-caching
Rate limitingFlask-Limiteruv add flask-limiter
Circuit breakerpybreakeruv add pybreaker
Feature flagsFlask-FeatureFlagsuv add flask-featureflags

Why Flask?

Flask1 is a lightweight, flexible microframework ideal for small-to-medium applications, APIs, and prototypes. It provides minimal abstractions while allowing extension through a rich ecosystem.

Use Flask when you need simplicity and flexibility; consider Django2 for larger, more structured applications with built-in admin, ORM, and authentication. Consider FastAPI3 for high-performance async APIs.

Project Structure

Projects MUST use the application factory pattern:

# src/myapp/__init__.py
from flask import Flask

def create_app(config: dict | None = None) -> Flask:
    app = Flask(__name__)

    if config:
        app.config.update(config)

    # Initialize extensions
    from myapp.extensions import db, login_manager, migrate
    db.init_app(app)
    migrate.init_app(app, db)
    login_manager.init_app(app)

    # Register blueprints
    from myapp.auth import auth_bp
    from myapp.api import api_bp
    app.register_blueprint(auth_bp, url_prefix="/auth")
    app.register_blueprint(api_bp, url_prefix="/api")

    return app

Why: The application factory pattern enables multiple app instances with different configurations, facilitates testing, and defers extension initialization until configuration is loaded. Every extension instance MUST be initialized here: an extension that is only constructed in extensions.py is never attached to the app, and the first request that depends on it fails at runtime.

my_app/
├── src/
│   └── myapp/
│       ├── __init__.py          # Application factory
│       ├── extensions.py        # Extension instances
│       ├── config.py            # Configuration classes
│       ├── models/
│       │   ├── __init__.py
│       │   └── user.py
│       ├── auth/
│       │   ├── __init__.py      # Blueprint definition
│       │   └── routes.py
│       ├── api/
│       │   ├── __init__.py
│       │   └── routes.py
│       └── templates/
├── tests/
│   ├── conftest.py
│   ├── test_auth.py
│   └── test_api.py
├── pyproject.toml
└── .env

Blueprints for Modularity

Projects MUST use blueprints4 to organize routes into logical modules:

# src/myapp/auth/routes.py
from flask import Blueprint, request, jsonify

auth_bp = Blueprint("auth", __name__)

@auth_bp.route("/login", methods=["POST"])
def login() -> tuple[dict, int]:
    credentials = request.get_json()
    # Handle authentication
    return {"token": token}, 200

@auth_bp.route("/logout", methods=["POST"])
def logout() -> tuple[dict, int]:
    # Handle logout
    return {"message": "Logged out"}, 200
# src/myapp/auth/__init__.py
from myapp.auth.routes import auth_bp

__all__ = ["auth_bp"]

Why: Blueprints organize code by feature, enable route prefixes and middleware per module, and make large applications maintainable by separating concerns.

Configuration Management

Projects MUST use environment-based configuration, and MUST NOT give SECRET_KEY a fallback value that a production configuration can inherit:

# src/myapp/config.py
import os
import secrets

class Config:
    SECRET_KEY: str | None = os.environ.get("SECRET_KEY")
    SQLALCHEMY_DATABASE_URI: str = os.environ["DATABASE_URL"]
    SQLALCHEMY_TRACK_MODIFICATIONS: bool = False

class DevelopmentConfig(Config):
    DEBUG: bool = True
    # Development-only: generated per process, so it is never a published
    # constant and ProductionConfig can never inherit it.
    SECRET_KEY: str = os.environ.get("SECRET_KEY") or secrets.token_urlsafe(32)

class ProductionConfig(Config):
    DEBUG: bool = False

class TestingConfig(Config):
    TESTING: bool = True
    SECRET_KEY: str = secrets.token_urlsafe(32)
    SQLALCHEMY_DATABASE_URI: str = "sqlite:///:memory:"

The factory MUST refuse to build an application whose SECRET_KEY is missing or shorter than 32 characters:

# src/myapp/__init__.py
MINIMUM_SECRET_KEY_LENGTH = 32

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)

    configs = {
        "development": "myapp.config.DevelopmentConfig",
        "production": "myapp.config.ProductionConfig",
        "testing": "myapp.config.TestingConfig",
    }
    app.config.from_object(configs.get(config_name, configs["development"]))

    secret_key = app.config.get("SECRET_KEY") or ""
    if len(secret_key) < MINIMUM_SECRET_KEY_LENGTH:
        raise RuntimeError(
            "SECRET_KEY is missing or shorter than "
            f"{MINIMUM_SECRET_KEY_LENGTH} characters; supply a random value "
            "from the deployment environment or secret manager."
        )

    return app

Don’t publish a fallback that production inherits:

# Don't: every deployment that forgets SECRET_KEY signs sessions with a
# value that is printed in this guide.
class Config:
    SECRET_KEY: str = os.environ.get("SECRET_KEY", "dev-key-change-in-prod")

class ProductionConfig(Config):  # inherits the published key
    DEBUG: bool = False

Why: Environment-based configuration separates deployment concerns from code, prevents secrets from being committed, and enables different settings per environment. SECRET_KEY signs the session cookie that Flask-Login5 uses to identify users, so a fallback shared by every reader of this guide lets anyone forge a session for any account. Flask requires a long random value that is never revealed6; failing startup makes a missing secret a deployment error instead of a silent authentication bypass. A development value generated per process keeps local work convenient without creating a constant that ProductionConfig can inherit.

Projects MUST test that a production configuration refuses to start without a supplied secret:

# tests/test_config.py
import importlib

import pytest

import myapp.config
from myapp import create_app

def test_production_refuses_to_start_without_a_secret(monkeypatch) -> None:
    monkeypatch.delenv("SECRET_KEY", raising=False)
    # Configuration classes read the environment when the module is imported.
    importlib.reload(myapp.config)

    with pytest.raises(RuntimeError, match="SECRET_KEY"):
        create_app("production")

def test_production_never_inherits_the_development_secret(monkeypatch) -> None:
    monkeypatch.delenv("SECRET_KEY", raising=False)
    importlib.reload(myapp.config)

    assert myapp.config.ProductionConfig.SECRET_KEY is None
    assert len(myapp.config.DevelopmentConfig.SECRET_KEY) >= 32

Testing with pytest

Projects MUST test Flask applications using pytest with Flask’s test client:

# tests/conftest.py
import pytest
from myapp import create_app
from myapp.extensions import db

@pytest.fixture
def app():
    app = create_app("testing")
    with app.app_context():
        db.create_all()
        yield app
        db.drop_all()

@pytest.fixture
def client(app):
    return app.test_client()

@pytest.fixture
def runner(app):
    return app.test_cli_runner()
# tests/test_auth.py
def test_login_success(client) -> None:
    response = client.post("/auth/login", json={
        "email": "test@example.com",
        "password": "password123"
    })
    assert response.status_code == 200
    assert "token" in response.json

def test_login_invalid_credentials(client) -> None:
    response = client.post("/auth/login", json={
        "email": "test@example.com",
        "password": "wrong"
    })
    assert response.status_code == 401

Why: Flask’s test client enables route testing without running a server. pytest fixtures provide isolated test database state and reusable app instances.

Extensions Ecosystem

Projects SHOULD use well-maintained Flask extensions:

ExtensionPurposeInstall
Flask-SQLAlchemy7ORM integrationuv add flask-sqlalchemy
Flask-Migrate8Database migrationsuv add flask-migrate
Flask-Login5User session managementuv add flask-login
Flask-WTF9Form validationuv add flask-wtf
Flask-CORS10CORS handlinguv add flask-cors
# src/myapp/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager

db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()

Why: Flask’s extension ecosystem provides battle-tested solutions for common needs while maintaining the framework’s lightweight philosophy.

Error Handling

Projects SHOULD implement consistent error handling:

# src/myapp/__init__.py
from flask import jsonify

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...

    @app.errorhandler(400)
    def bad_request(error):
        return jsonify({"error": "Bad request", "message": str(error)}), 400

    @app.errorhandler(404)
    def not_found(error):
        return jsonify({"error": "Not found"}), 404

    @app.errorhandler(500)
    def internal_error(error):
        return jsonify({"error": "Internal server error"}), 500

    return app

Request Validation

Projects SHOULD validate request data:

from flask import Blueprint, request, jsonify
from pydantic import BaseModel, EmailStr, ValidationError

api_bp = Blueprint("api", __name__)

class UserCreate(BaseModel):
    email: EmailStr
    name: str
    age: int | None = None

@api_bp.route("/users", methods=["POST"])
def create_user():
    try:
        data = UserCreate(**request.get_json())
    except ValidationError as e:
        return jsonify({"errors": e.errors()}), 400

    # Create user with validated data
    return jsonify({"user": data.model_dump()}), 201

Security

Projects MUST implement comprehensive security including authentication, authorization, and token management.

Why Security

Security is foundational for any production application. Flask-Login5 handles session management, Authlib11 provides OAuth 2.0/OpenID Connect integration, PyJWT12 enables stateless token authentication, and Flask-Principal13 manages role-based permissions. Using established libraries prevents common security vulnerabilities.

Authentication with Flask-Login

Projects MUST use Flask-Login for session-based authentication:

# src/myapp/extensions.py
from flask_login import LoginManager

login_manager = LoginManager()
login_manager.login_view = "auth.login"
# src/myapp/models/user.py
from flask_login import UserMixin
from myapp.extensions import db

class User(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password_hash = db.Column(db.String(256), nullable=False)
    fs_uniquifier = db.Column(db.String(64), unique=True, nullable=False)

    def get_id(self) -> str:
        return self.fs_uniquifier  # Enables session invalidation without changing user ID
# src/myapp/__init__.py
from myapp.extensions import login_manager
from myapp.models.user import User

@login_manager.user_loader
def load_user(user_id: str) -> User | None:
    return User.query.filter_by(fs_uniquifier=user_id).first()

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...

    # Required: without this call current_user and login_required raise
    # AttributeError: 'Flask' object has no attribute 'login_manager'.
    login_manager.init_app(app)

    # ... register blueprints ...
    return app

Routes establish and end the session with login_user and logout_user:

# src/myapp/auth/routes.py
from flask import request
from flask_login import login_required, login_user, logout_user
from werkzeug.security import check_password_hash

from myapp.models.user import User

@auth_bp.route("/login", methods=["POST"])
def login() -> tuple[dict, int]:
    credentials = request.get_json()
    user = User.query.filter_by(email=credentials["email"]).first()
    if user is None or not check_password_hash(
        user.password_hash, credentials["password"]
    ):
        return {"error": "Invalid credentials"}, 401

    login_user(user)
    return {"message": "Logged in"}, 200

@auth_bp.route("/logout", methods=["POST"])
@login_required
def logout() -> tuple[dict, int]:
    logout_user()
    return {"message": "Logged out"}, 200

Why: Flask-Login stores the value returned by get_id() in the signed session cookie and rebuilds current_user from it through the user_loader. Constructing LoginManager() in extensions.py only creates the object; init_app is what attaches it to an application instance5. An uninitialized manager is not a login failure but a server error: every login_required route returns 500 with AttributeError: 'Flask' object has no attribute 'login_manager'.

Projects MUST cover both the anonymous and the authenticated path:

# tests/test_auth.py
def test_anonymous_request_is_redirected_to_login(client) -> None:
    response = client.get("/private")

    assert response.status_code == 302
    assert "/auth/login" in response.headers["Location"]

def test_authenticated_request_reaches_the_route(client, user) -> None:
    login = client.post(
        "/auth/login",
        json={"email": "test@example.com", "password": "password123"},
    )
    assert login.status_code == 200

    response = client.get("/private")

    assert response.status_code == 200

JWT Token Authentication with PyJWT

Projects SHOULD use RS256 asymmetric signing for JWT tokens in distributed systems:

# src/myapp/auth/tokens.py
import jwt
from datetime import datetime, timedelta, timezone
from flask import current_app

def create_access_token(user_id: int, expires_minutes: int = 15) -> str:
    """Create a short-lived access token using RS256."""
    payload = {
        "sub": str(user_id),
        "iat": datetime.now(timezone.utc),
        "exp": datetime.now(timezone.utc) + timedelta(minutes=expires_minutes),
        "type": "access",
    }
    return jwt.encode(
        payload,
        current_app.config["JWT_PRIVATE_KEY"],
        algorithm="RS256",
    )

def verify_access_token(token: str) -> dict | None:
    """Verify token using public key."""
    try:
        return jwt.decode(
            token,
            current_app.config["JWT_PUBLIC_KEY"],
            algorithms=["RS256"],  # Explicitly restrict algorithm
        )
    except jwt.ExpiredSignatureError:
        return None
    except jwt.InvalidTokenError:
        return None
# src/myapp/auth/decorators.py
from functools import wraps
from flask import request, jsonify
from myapp.auth.tokens import verify_access_token

def jwt_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            return jsonify({"error": "Missing authorization header"}), 401

        token = auth_header.split(" ", 1)[1]
        payload = verify_access_token(token)
        if payload is None:
            return jsonify({"error": "Invalid or expired token"}), 401

        request.current_user_id = int(payload["sub"])
        return f(*args, **kwargs)
    return decorated

OAuth 2.0 with Authlib

Projects SHOULD use Authlib for OAuth 2.0 and OpenID Connect integration, and MUST enable PKCE with S256:

# src/myapp/extensions.py
from authlib.integrations.flask_client import OAuth

oauth = OAuth()
# src/myapp/__init__.py
def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...

    from myapp.extensions import oauth
    oauth.init_app(app)

    oauth.register(
        name="google",
        client_id=app.config["GOOGLE_CLIENT_ID"],
        client_secret=app.config["GOOGLE_CLIENT_SECRET"],
        server_metadata_url=(
            "https://accounts.google.com/.well-known/openid-configuration"
        ),
        client_kwargs={
            "scope": "openid email profile",
            # Authlib adds code_challenge and code_verifier for PKCE.
            "code_challenge_method": "S256",
        },
    )

    return app

The provider identity MUST be the issuer and subject pair, not the email address, and it MUST be stored against a local account:

# src/myapp/models/oauth.py
from myapp.extensions import db

class OAuthIdentity(db.Model):
    """A provider identity (issuer plus subject) linked to a local account."""

    __tablename__ = "oauth_identity"
    __table_args__ = (db.UniqueConstraint("issuer", "subject"),)

    id = db.Column(db.Integer, primary_key=True)
    issuer = db.Column(db.String(255), nullable=False)
    subject = db.Column(db.String(255), nullable=False)
    user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
    user = db.relationship("User")
# src/myapp/auth/routes.py
import secrets

from authlib.integrations.base_client import OAuthError
from flask import abort, current_app, redirect, url_for
from flask_login import login_user
from werkzeug.security import generate_password_hash

from myapp.extensions import db, oauth
from myapp.models.oauth import OAuthIdentity
from myapp.models.user import User

@auth_bp.route("/login/google")
def google_login():
    redirect_uri = url_for("auth.google_callback", _external=True)
    return oauth.google.authorize_redirect(redirect_uri)

@auth_bp.route("/callback/google")
def google_callback():
    try:
        token = oauth.google.authorize_access_token()
    except OAuthError as error:
        current_app.logger.warning("Google callback rejected: %s", error.error)
        abort(401, description="OAuth callback failed")

    claims = token.get("userinfo")
    if claims is None or "sub" not in claims or "iss" not in claims:
        abort(401, description="Provider returned no verifiable identity")
    if not claims.get("email_verified", False):
        abort(403, description="Provider has not verified this email address")

    login_user(link_local_user(claims))
    return redirect(url_for("main.index"))

def link_local_user(claims: dict) -> User:
    """Map a verified provider identity to a local account."""
    identity = OAuthIdentity.query.filter_by(
        issuer=claims["iss"], subject=claims["sub"]
    ).first()
    if identity is not None:
        return identity.user

    user = User.query.filter_by(email=claims["email"]).first()
    if user is None:
        user = User(
            email=claims["email"],
            # No local password: sign-in happens through the provider only.
            password_hash=generate_password_hash(secrets.token_urlsafe(32)),
            fs_uniquifier=secrets.token_urlsafe(32),
        )
        db.session.add(user)

    db.session.add(
        OAuthIdentity(issuer=claims["iss"], subject=claims["sub"], user=user)
    )
    db.session.commit()
    return user

Redirect URIs registered with the provider MUST match the value the application sends exactly, and the callback MUST NOT be reachable through an open redirector.

Why: Storing the email in session leaves current_user anonymous, so every login_required route still rejects the user; login_user is what establishes the Flask-Login session5. An email address is not a stable identifier — providers let users change it and reuse it — whereas the issuer and subject pair is stable for the lifetime of the account, so linking on (iss, sub) prevents one user inheriting another’s account after an address change. RFC 970014 requires exact string matching of redirect URIs (Section 2.1) and recommends PKCE for confidential clients with S256 as the only challenge method that does not expose the verifier (Section 2.1.1); Authlib generates and verifies the challenge when code_challenge_method is set in client_kwargs15. Authlib validates the state value and parses the ID token during authorize_access_token()15, which makes a forged or replayed callback raise OAuthError rather than silently succeed — but only the application can decide whether an unverified email may be linked.

Projects MUST test the state, replay, and rejected-claim paths:

# tests/test_oauth.py
from urllib.parse import parse_qs, urlparse

from myapp.extensions import oauth
from myapp.models.user import User

ISSUER = "https://accounts.google.com"

def start_login(client) -> str:
    """Run the redirect leg and return the state Authlib stored in session."""
    response = client.get("/auth/login/google")
    assert response.status_code == 302
    query = parse_qs(urlparse(response.headers["Location"]).query)
    assert query["code_challenge_method"] == ["S256"]
    return query["state"][0]

def test_callback_rejects_an_unknown_state(client) -> None:
    response = client.get("/auth/callback/google?code=abc&state=forged")

    assert response.status_code == 401

def test_callback_replay_is_rejected(client, monkeypatch) -> None:
    state = start_login(client)
    monkeypatch.setattr(
        oauth.google,
        "fetch_access_token",
        lambda **kwargs: {
            "access_token": "provider-token",
            "userinfo": {
                "iss": ISSUER,
                "sub": "1234567890",
                "email": "person@example.com",
                "email_verified": True,
            },
        },
    )
    first = client.get(f"/auth/callback/google?code=abc&state={state}")
    assert first.status_code == 302

    replayed = client.get(f"/auth/callback/google?code=abc&state={state}")

    assert replayed.status_code == 401

def test_callback_rejects_an_unverified_email(client, monkeypatch) -> None:
    state = start_login(client)
    monkeypatch.setattr(
        oauth.google,
        "fetch_access_token",
        lambda **kwargs: {
            "access_token": "provider-token",
            "userinfo": {
                "iss": ISSUER,
                "sub": "2222",
                "email": "person@example.com",
                "email_verified": False,
            },
        },
    )

    response = client.get(f"/auth/callback/google?code=abc&state={state}")

    assert response.status_code == 403
    assert User.query.count() == 0

Role-Based Authorization with Flask-Principal

Projects SHOULD use Flask-Principal for permission management:

# src/myapp/extensions.py
from flask_principal import Principal, Permission, RoleNeed

principals = Principal()
admin_permission = Permission(RoleNeed("admin"))
editor_permission = Permission(RoleNeed("editor"))
# src/myapp/__init__.py
from flask_principal import identity_loaded, UserNeed, RoleNeed
from flask_login import current_user

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...

    from myapp.extensions import principals
    principals.init_app(app)

    @identity_loaded.connect_via(app)
    def on_identity_loaded(sender, identity):
        identity.user = current_user
        if hasattr(current_user, "id"):
            identity.provides.add(UserNeed(current_user.id))
        if hasattr(current_user, "roles"):
            for role in current_user.roles:
                identity.provides.add(RoleNeed(role.name))

    return app
# src/myapp/admin/routes.py
from flask import abort
from myapp.extensions import admin_permission

@admin_bp.route("/dashboard")
@login_required
def admin_dashboard():
    if not admin_permission.can():
        abort(403)
    return render_template("admin/dashboard.html")

# Or use as decorator
@admin_bp.route("/users")
@login_required
@admin_permission.require(http_exception=403)
def manage_users():
    return render_template("admin/users.html")

WebSocket

Projects requiring real-time communication SHOULD use Flask-SocketIO16.

Why WebSocket

WebSocket enables bidirectional communication between client and server, essential for chat applications, live updates, and collaborative features. Flask-SocketIO provides a clean abstraction over WebSocket with fallback support and room-based messaging.

Server Selection

Projects SHOULD use Gunicorn’s threaded worker with simple-websocket, because eventlet is deprecated and the gevent WebSocket layer is archived:

ServerStatusUse Case
Threading + simple-websocketRecommendedProduction default; best library compatibility
gevent + gevent-websocketgevent-websocket archivedExisting greenlet stacks only
eventletDeprecatedLegacy applications only

Configuration

# src/myapp/extensions.py
from flask_socketio import SocketIO

socketio = SocketIO()
# src/myapp/__init__.py
def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...

    from myapp.extensions import socketio
    socketio.init_app(
        app,
        async_mode="threading",  # Must match the deployed Gunicorn worker
        message_queue=app.config.get("SOCKETIO_MESSAGE_QUEUE"),  # Redis for multi-process
        cors_allowed_origins=app.config.get("CORS_ORIGINS", "*"),
        logger=app.config.get("DEBUG", False),
        engineio_logger=app.config.get("DEBUG", False),
    )

    return app
# src/myapp/realtime/events.py
from flask_socketio import emit, join_room, leave_room
from myapp.extensions import socketio

@socketio.on("connect")
def handle_connect():
    emit("status", {"message": "Connected"})

@socketio.on("join")
def handle_join(data: dict):
    room = data["room"]
    join_room(room)
    emit("status", {"message": f"Joined {room}"}, to=room)

@socketio.on("message")
def handle_message(data: dict):
    room = data.get("room", "general")
    emit("message", data, to=room, include_self=False)

Production Deployment

Projects MUST install the server they invoke, and SHOULD run Flask-SocketIO behind Gunicorn’s threaded worker:

# Install dependencies: the server is part of the dependency set
uv add "flask-socketio==5.6.1" "gunicorn==26.2.0" "simple-websocket==1.1.0"

# Run the threaded worker; simple-websocket supplies the WebSocket transport
gunicorn -w 1 --threads 100 --bind 0.0.0.0:5000 "myapp:create_app()"

The async_mode passed to socketio.init_app MUST match the deployed worker: "threading" for the command above, "gevent" for a greenlet stack.

Why: The gevent WebSocket worker requires five packages that must all be present — Flask-SocketIO, gevent, gevent-websocket, packaging, and Gunicorn itself — and its WebSocket layer, gevent-websocket, has been archived by its author17, so it receives no compatibility fixes. Installing only Flask-SocketIO, gevent, and gevent-websocket leaves no gunicorn executable and makes the documented worker import fail with ModuleNotFoundError: No module named 'gunicorn'. The threaded worker with simple-websocket is a maintained path documented by Flask-SocketIO16 and avoids monkey patching entirely. Gunicorn’s load balancer cannot do sticky sessions, so -w 1 is required in every variant; scale by running several single-worker instances behind nginx.

Projects MUST smoke test the deployment command, not only the application:

# tests/smoke/test_websocket.py — run against a started Gunicorn process
import socketio

def test_websocket_round_trip() -> None:
    client = socketio.Client()
    received: list[dict] = []
    client.on("pong_test", received.append)

    client.connect("http://127.0.0.1:5000", transports=["websocket"])
    client.emit("ping_test", {"hello": "world"})
    client.sleep(1)
    client.disconnect()

    assert client.transport() == "websocket"
    assert received == [{"echo": {"hello": "world"}}]

Projects that require a greenlet stack MUST install every dependency the worker imports, including packaging, which Gunicorn’s gevent worker needs but does not declare:

uv add "flask-socketio==5.6.1" "gunicorn==26.2.0" "gevent==26.8.0" \
    "gevent-websocket==0.10.1" "packaging==25.0"

gunicorn -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker \
    -w 1 --bind 0.0.0.0:5000 "myapp:create_app()"

Projects using multiple worker processes MUST configure a message queue:

# src/myapp/config.py
class ProductionConfig(Config):
    SOCKETIO_MESSAGE_QUEUE = os.environ.get("REDIS_URL", "redis://localhost:6379/0")

Monkey Patching

Projects using gevent MUST apply monkey patching at the top of the entry point:

# wsgi.py
from gevent import monkey
monkey.patch_all()

from myapp import create_app, socketio

app = create_app("production")

if __name__ == "__main__":
    socketio.run(app)

Performance

Projects SHOULD implement performance monitoring and profiling.

Why Performance Monitoring

Performance issues in production require visibility into application behavior. Flask-Profiler18 provides request-level profiling with a web UI, py-spy19 enables live process profiling without code changes, and prometheus_client20 exposes metrics for monitoring infrastructure.

Flask-Profiler for Development

Projects MAY use Flask-Profiler during development:

# src/myapp/__init__.py
def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...

    if app.config.get("PROFILING_ENABLED"):
        import flask_profiler
        app.config["flask_profiler"] = {
            "enabled": True,
            "storage": {"engine": "sqlite"},
            "basicAuth": {"enabled": True, "username": "admin", "password": "secret"},
            "ignore": ["^/static/.*"],
        }
        flask_profiler.init_app(app)

    return app

py-spy for Production Profiling

Projects SHOULD use py-spy for production performance analysis:

# Attach to running Flask process (no code changes required)
py-spy top --pid $(pgrep -f "gunicorn.*myapp")

# Generate flame graph
py-spy record -o profile.svg --pid $(pgrep -f "gunicorn.*myapp")

Prometheus Metrics

Projects SHOULD expose Prometheus metrics for production monitoring:

# src/myapp/extensions.py
from prometheus_client import Counter, Histogram

REQUEST_COUNT = Counter(
    "flask_request_total",
    "Total request count",
    ["method", "endpoint", "status"],
)
REQUEST_LATENCY = Histogram(
    "flask_request_latency_seconds",
    "Request latency",
    ["method", "endpoint"],
)
# src/myapp/__init__.py
from time import time
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...

    from myapp.extensions import REQUEST_COUNT, REQUEST_LATENCY

    @app.before_request
    def before_request():
        request.start_time = time()

    @app.after_request
    def after_request(response):
        latency = time() - getattr(request, "start_time", time())
        REQUEST_COUNT.labels(
            method=request.method,
            endpoint=request.endpoint or "unknown",
            status=response.status_code,
        ).inc()
        REQUEST_LATENCY.labels(
            method=request.method,
            endpoint=request.endpoint or "unknown",
        ).observe(latency)
        return response

    @app.route("/metrics")
    def metrics():
        return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST}

    return app

Background Jobs

Projects with long-running tasks MUST use a task queue.

Why Task Queues

HTTP requests should complete quickly. Background task queues offload expensive operations like sending emails, processing files, or calling external APIs. This prevents request timeouts and improves user experience.

Task Queue Comparison

FeatureCelery21RQ22Huey23
ComplexityHigherLowerLower
BrokersRedis, RabbitMQ, etc.Redis onlyRedis
SchedulingFull crontabBasicCrontab-like
MonitoringFlower UIrq-dashboardBuilt-in
Best forLarge scale, complex workflowsSimple queuesLightweight apps

Celery with Application Factory

Projects using Celery MUST integrate with the application factory pattern:

# src/myapp/extensions.py
from celery import Celery, Task

def celery_init_app(app) -> Celery:
    class FlaskTask(Task):
        def __call__(self, *args, **kwargs):
            with app.app_context():
                return self.run(*args, **kwargs)

    celery_app = Celery(app.name, task_cls=FlaskTask)
    celery_app.config_from_object(app.config["CELERY"])
    celery_app.set_default()
    app.extensions["celery"] = celery_app
    return celery_app
# src/myapp/config.py
class Config:
    CELERY = {
        "broker_url": os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0"),
        "result_backend": os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0"),
        "task_ignore_result": True,
        "task_serializer": "json",
        "result_serializer": "json",
        "accept_content": ["json"],
    }
# src/myapp/__init__.py
from myapp.extensions import celery_init_app

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    app.config.from_object(configs[config_name])
    celery_init_app(app)
    return app
# src/myapp/tasks.py
from celery import shared_task

@shared_task(bind=True, max_retries=3)
def send_email(self, to: str, subject: str, body: str) -> None:
    """Send email in background with retry logic."""
    try:
        # Send email logic
        pass
    except Exception as exc:
        raise self.retry(exc=exc, countdown=60)

RQ for Simpler Queues

Projects with simpler requirements MAY use RQ22 directly, and MUST NOT use Flask-RQ2:

# src/myapp/config.py
class Config:
    RQ_REDIS_URL: str = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
    RQ_QUEUE_NAME: str = os.environ.get("RQ_QUEUE_NAME", "default")
# src/myapp/extensions.py
from flask import Flask
from redis import Redis
from rq import Queue

def rq_init_app(app: Flask) -> Queue:
    """Create this app instance's RQ queue and store it in app.extensions."""
    connection = Redis.from_url(app.config["RQ_REDIS_URL"])
    queue = Queue(app.config["RQ_QUEUE_NAME"], connection=connection)
    app.extensions["rq_queue"] = queue
    return queue
# src/myapp/__init__.py
from myapp.extensions import rq_init_app

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...
    rq_init_app(app)
    return app
# src/myapp/tasks.py
def process_upload(file_id: int) -> str:
    """Process an uploaded file in a worker process."""
    return f"processed {file_id}"
# src/myapp/api/routes.py
from flask import current_app
from myapp.tasks import process_upload

@api_bp.route("/uploads/<int:file_id>/process", methods=["POST"])
def enqueue_processing(file_id: int) -> tuple[dict, int]:
    """Hand the slow work to a worker and return the job ID."""
    job = current_app.extensions["rq_queue"].enqueue(process_upload, file_id)
    return {"job_id": job.id}, 202
uv add "rq==2.12.0"

# Run an RQ 2.12 worker with the built-in scheduler
rq worker --url "$REDIS_URL" --path src --with-scheduler default

Jobs that need application configuration or the database MUST run under an application context:

# src/myapp/worker.py
from rq import Worker

from myapp import create_app

def main() -> None:
    app = create_app("production")
    with app.app_context():
        queue = app.extensions["rq_queue"]
        Worker([queue], connection=queue.connection).work(with_scheduler=True)

if __name__ == "__main__":
    main()

Run that worker with python -m myapp.worker.

Why: Flask-RQ2’s last release was 18.3 in December 201824 and it imports pkg_resources at module scope, which current setuptools no longer ships, so from flask_rq2 import RQ fails on Python 3.14 with ModuleNotFoundError: No module named 'pkg_resources'. RQ itself is maintained, tests on Python 3.14, and needs no Flask-specific wrapper: a Queue on app.extensions follows the same factory-scoped pattern as Celery above, so each application instance owns its own connection instead of sharing module state.

# tests/test_rq.py
import fakeredis
from rq import Queue, SimpleWorker

from myapp.tasks import process_upload

def test_enqueue_and_run_a_job() -> None:
    connection = fakeredis.FakeRedis()
    queue = Queue("default", connection=connection)

    job = queue.enqueue(process_upload, 42)
    SimpleWorker([queue], connection=connection).work(burst=True)

    assert job.latest_result().return_value == "processed 42"

Async Views

Projects using Flask 2.0+ MAY use async views for I/O-bound operations.

Why Async Views

Async views enable concurrent I/O operations within a single request, improving throughput for requests that call multiple external services. However, Flask remains WSGI-based; for fully async applications, consider Quart25 or FastAPI3.

Installation

uv add "flask[async]"

Basic Async Views

# src/myapp/api/routes.py
import asyncio
import aiohttp
from flask import Blueprint, jsonify

api_bp = Blueprint("api", __name__)

@api_bp.route("/aggregate")
async def aggregate_data():
    """Fetch data from multiple sources concurrently."""
    async with aiohttp.ClientSession() as session:
        tasks = [
            fetch_service(session, "https://api.service1.com/data"),
            fetch_service(session, "https://api.service2.com/data"),
            fetch_service(session, "https://api.service3.com/data"),
        ]
        results = await asyncio.gather(*tasks, return_exceptions=True)

    return jsonify({"results": [r for r in results if not isinstance(r, Exception)]})

async def fetch_service(session: aiohttp.ClientSession, url: str) -> dict:
    async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
        return await resp.json()

Limitations

Projects SHOULD be aware of async limitations in Flask:

  • Flask extensions may not be async-compatible
  • Performance is lower than async-first frameworks (Quart, FastAPI)
  • Background tasks should use task queues, not asyncio.create_task()
  • Only asyncio is supported (not trio or curio)
# Do: Use task queue for background work
@api_bp.route("/process")
async def process_request():
    data = await fetch_external_data()
    send_notification.delay(data)  # Celery task
    return jsonify({"status": "processing"})

# Don't: Spawn background tasks directly
@api_bp.route("/process")
async def process_request_wrong():
    data = await fetch_external_data()
    asyncio.create_task(send_notification(data))  # Task may be cancelled!
    return jsonify({"status": "processing"})

Caching

Projects SHOULD implement caching for frequently accessed data.

Why Caching

Caching reduces database load and improves response times. Flask-Caching26 provides a unified interface with multiple backends. Redis is recommended for production due to its persistence, atomic operations, and distributed support.

Configuration

# src/myapp/extensions.py
from flask_caching import Cache

cache = Cache()
# src/myapp/config.py
class Config:
    CACHE_TYPE = "RedisCache"
    CACHE_REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
    CACHE_DEFAULT_TIMEOUT = 300
    CACHE_KEY_PREFIX = "myapp_"

class DevelopmentConfig(Config):
    CACHE_TYPE = "SimpleCache"  # In-memory for development

class TestingConfig(Config):
    CACHE_TYPE = "NullCache"  # Disable caching in tests
# src/myapp/__init__.py
from myapp.extensions import cache

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...
    cache.init_app(app)
    return app

Usage Patterns

# src/myapp/api/routes.py
from myapp.extensions import cache

@api_bp.route("/products")
@cache.cached(timeout=60, query_string=True)
def list_products():
    """Cache product list for 60 seconds, varying by query string."""
    products = Product.query.all()
    return jsonify([p.to_dict() for p in products])

@api_bp.route("/products/<int:product_id>")
@cache.cached(timeout=300)
def get_product(product_id: int):
    """Cache individual product for 5 minutes."""
    product = Product.query.get_or_404(product_id)
    return jsonify(product.to_dict())

@api_bp.route("/products/<int:product_id>", methods=["PUT"])
def update_product(product_id: int):
    """Invalidate cache on update."""
    product = Product.query.get_or_404(product_id)
    # ... update logic ...
    cache.delete(f"view//api/products/{product_id}")
    cache.delete_memoized(list_products)
    return jsonify(product.to_dict())

Memoization for Functions

# src/myapp/services/analytics.py
from myapp.extensions import cache

@cache.memoize(timeout=3600)
def compute_user_stats(user_id: int) -> dict:
    """Cache expensive computation per user for 1 hour."""
    # Expensive computation
    return {"user_id": user_id, "stats": computed_stats}

# Invalidate when needed
cache.delete_memoized(compute_user_stats, user_id)

Rate Limiting

Projects exposing APIs SHOULD implement rate limiting.

Why Rate Limiting

Rate limiting protects against abuse, prevents resource exhaustion, and ensures fair access. Flask-Limiter27 provides decorators and configuration for per-route limits with multiple storage backends.

Configuration

# src/myapp/extensions.py
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    key_func=get_remote_address,
    default_limits=["200 per day", "50 per hour"],
    storage_uri="redis://localhost:6379/1",
    storage_options={"socket_connect_timeout": 30},
    strategy="fixed-window",
)
# src/myapp/__init__.py
from myapp.extensions import limiter

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...

    limiter.init_app(app)

    @app.errorhandler(429)
    def ratelimit_handler(e):
        return jsonify({
            "error": "Rate limit exceeded",
            "message": str(e.description),
        }), 429

    return app

Route-Specific Limits

# src/myapp/api/routes.py
from myapp.extensions import limiter

@api_bp.route("/search")
@limiter.limit("30 per minute")
def search():
    """Limit search to 30 requests per minute."""
    return jsonify({"results": []})

@api_bp.route("/login", methods=["POST"])
@limiter.limit("5 per minute")
def login():
    """Strict limit on login attempts."""
    return jsonify({"token": "..."})

@api_bp.route("/health")
@limiter.exempt
def health_check():
    """Exempt health checks from rate limiting."""
    return jsonify({"status": "healthy"})

API Key-Based Limiting

The limiter MUST NOT be keyed by a raw request header. Authentication MUST resolve the credential to a stored identifier first:

# src/myapp/models/api_key.py
from myapp.extensions import db

class ApiKey(db.Model):
    """A hashed API credential; the raw key is shown once at creation."""

    __tablename__ = "api_key"

    id = db.Column(db.Integer, primary_key=True)
    key_digest = db.Column(db.String(64), unique=True, nullable=False)
    revoked = db.Column(db.Boolean, nullable=False, default=False)
    user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
# src/myapp/auth/api_keys.py
import hashlib
from collections.abc import Callable
from functools import wraps

from flask import abort, g, request

from myapp.models.api_key import ApiKey

def digest_api_key(raw_key: str) -> str:
    """Hash a raw API key for storage and lookup."""
    return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()

def authenticate_api_key() -> None:
    """Resolve X-API-Key to a stored key ID before any limit is applied."""
    g.api_key_id = None
    presented = request.headers.get("X-API-Key")
    if presented is None:
        return

    api_key = ApiKey.query.filter_by(key_digest=digest_api_key(presented)).first()
    if api_key is not None and not api_key.revoked:
        g.api_key_id = str(api_key.id)

def api_key_required(view: Callable) -> Callable:
    """Reject unknown or revoked credentials after the limit is applied."""

    @wraps(view)
    def wrapper(*args, **kwargs):
        if g.get("api_key_id") is None:
            abort(401, description="Valid X-API-Key required")
        return view(*args, **kwargs)

    return wrapper
# src/myapp/extensions.py
from flask import g
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

def api_key_or_address() -> str:
    """Key limits by the authenticated API key, falling back to the address."""
    key_id = g.get("api_key_id")
    if key_id is not None:
        return f"key:{key_id}"
    return f"ip:{get_remote_address()}"

limiter = Limiter(
    key_func=api_key_or_address,
    default_limits=["1000 per hour"],
    storage_uri=os.environ.get("REDIS_URL", "redis://localhost:6379/1"),
)

Authentication MUST be registered before limiter.init_app, and the application MUST apply ProxyFix with the exact number of trusted proxies when it runs behind one:

# src/myapp/__init__.py
from werkzeug.middleware.proxy_fix import ProxyFix

from myapp.auth.api_keys import authenticate_api_key
from myapp.extensions import limiter

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...

    if app.config.get("TRUSTED_PROXY_COUNT"):
        app.wsgi_app = ProxyFix(
            app.wsgi_app,
            x_for=app.config["TRUSTED_PROXY_COUNT"],
            x_proto=app.config["TRUSTED_PROXY_COUNT"],
            x_host=0,
            x_prefix=0,
        )

    app.before_request(authenticate_api_key)  # populates g.api_key_id
    limiter.init_app(app)                     # keys on the trusted identity

    return app
# src/myapp/api/routes.py
from myapp.auth.api_keys import api_key_required
from myapp.extensions import limiter

@api_bp.route("/reports")
@limiter.limit("3 per minute")
@api_key_required
def reports():
    """The limiter counts the request before the credential is rejected."""
    return jsonify({"reports": []})

Why: request.headers.get("X-API-Key", ...) lets an unauthenticated caller choose its own quota bucket, so rotating the header gives an attacker an unlimited number of fresh buckets and the limit protects nothing. Keying on a database identifier the caller cannot influence removes that choice, and the address fallback is applied only to callers that presented no credential. Ordering matters twice: authenticate_api_key must run before limiter.init_app registers its own before_request, or g.api_key_id is still unset when key_func runs; and rejection must happen inside the view chain, below limiter.limit, or an aborted request never reaches the limiter and an attacker can spend unlimited invalid credentials. get_remote_address reads request.remote_addr, which is the proxy’s address unless ProxyFix is configured with the exact hop count28 — a guessed count lets clients spoof X-Forwarded-For and forge the fallback bucket.

# tests/test_rate_limit.py
def test_rotating_invalid_keys_cannot_reset_the_bucket(client) -> None:
    """An attacker rotating X-API-Key stays in one address bucket."""
    statuses = [
        client.get(
            "/api/reports", headers={"X-API-Key": f"attacker-key-{n}"}
        ).status_code
        for n in range(1, 5)
    ]

    assert statuses == [401, 401, 401, 429]

def test_authenticated_key_shares_one_bucket_across_addresses(
    client, api_key
) -> None:
    """Spoofed forwarding headers give an authenticated key no new bucket."""
    statuses = [
        client.get(
            "/api/reports",
            headers={"X-API-Key": api_key, "X-Forwarded-For": f"10.0.0.{n}"},
        ).status_code
        for n in range(1, 5)
    ]

    assert statuses == [200, 200, 200, 429]

Circuit Breakers

Projects calling external services SHOULD implement circuit breakers.

Why Circuit Breakers

Circuit breakers prevent cascading failures when external services become unavailable. They “open” after repeated failures, failing fast instead of waiting for timeouts, and periodically “test” if the service has recovered. pybreaker29 provides a Python implementation of this pattern.

Configuration

# src/myapp/services/external.py
import pybreaker
import requests

# Create circuit breaker with Redis storage for distributed state
external_api_breaker = pybreaker.CircuitBreaker(
    fail_max=5,
    reset_timeout=30,
    state_storage=pybreaker.CircuitRedisStorage(
        pybreaker.STATE_CLOSED,
        redis_object=redis_client,
        namespace="external_api",
    ),
)

Usage Patterns

Exclusions MUST inspect the response status, because raise_for_status() raises the same HTTPError for 4xx and 5xx:

# src/myapp/services/payment.py
import pybreaker
import requests
from flask import current_app

def is_client_error(exc: BaseException) -> bool:
    """Return True for 4xx responses, which are caller errors, not outages."""
    response = getattr(exc, "response", None)
    return response is not None and 400 <= response.status_code < 500

payment_breaker = pybreaker.CircuitBreaker(
    fail_max=3,
    reset_timeout=60,
    # 5xx responses and transport failures still count towards fail_max.
    exclude=[is_client_error],
)

class PaymentService:
    @payment_breaker
    def process_payment(
        self, amount: float, token: str, idempotency_key: str
    ) -> dict:
        """Process payment with circuit breaker protection."""
        response = requests.post(
            current_app.config["PAYMENT_API_URL"],
            json={"amount": amount, "token": token},
            headers={"Idempotency-Key": idempotency_key},
            timeout=10,
        )
        response.raise_for_status()
        return response.json()

    def process_payment_safe(
        self, amount: float, token: str, idempotency_key: str
    ) -> dict | None:
        """Process payment with fallback handling."""
        try:
            return self.process_payment(amount, token, idempotency_key)
        except pybreaker.CircuitBreakerError:
            current_app.logger.warning(
                "Payment service circuit open, queuing for retry"
            )
            queue_payment_retry.delay(amount, token, idempotency_key)
            return None

Don’t exclude the exception class:

# Don't: raise_for_status() raises HTTPError for 500s too, so a total
# outage leaves the breaker closed with fail_counter at 0.
payment_breaker = pybreaker.CircuitBreaker(
    fail_max=3,
    exclude=[requests.exceptions.HTTPError],
)

Retries queued while the circuit is open MUST carry the idempotency key of the original attempt.

Why: pybreaker treats an excluded exception as a business outcome rather than a system failure29, and requests signals both “the caller sent bad data” and “the service is broken” with HTTPError. Excluding the class means five consecutive 500 responses leave the breaker closed with fail_counter at 0, so the breaker never opens and every request keeps paying the full timeout. A predicate that reads exc.response.status_code excludes only the 4xx range and leaves 5xx, ConnectionError, and Timeout counting. Because a payment request may have been processed before the response failed, replaying it without the original idempotency key can charge the customer twice; the key makes the retry safe to repeat.

# tests/test_payment_breaker.py
import requests

from myapp.services import payment
from myapp.services.payment import PaymentService, payment_breaker

class FakeResponse:
    def __init__(self, status_code: int) -> None:
        self.status_code = status_code

    def raise_for_status(self) -> None:
        if self.status_code >= 400:
            raise requests.exceptions.HTTPError(
                f"{self.status_code} Error", response=self
            )

    def json(self) -> dict:
        return {"status": "ok"}

def call_five_times(monkeypatch, behavior) -> None:
    monkeypatch.setattr(payment.requests, "post", behavior)
    service = PaymentService()
    for attempt in range(5):
        try:
            service.process_payment(10.0, "card-token", f"key-{attempt}")
        except Exception:
            pass

def test_client_errors_do_not_trip_the_breaker(app, monkeypatch) -> None:
    call_five_times(monkeypatch, lambda *a, **k: FakeResponse(404))

    assert payment_breaker.current_state == "closed"
    assert payment_breaker.fail_counter == 0

def test_server_errors_trip_the_breaker(app, monkeypatch) -> None:
    call_five_times(monkeypatch, lambda *a, **k: FakeResponse(500))

    assert payment_breaker.current_state == "open"

def test_transport_failures_trip_the_breaker(app, monkeypatch) -> None:
    def explode(*args, **kwargs):
        raise requests.exceptions.ConnectionError("refused")

    call_five_times(monkeypatch, explode)

    assert payment_breaker.current_state == "open"

Monitoring Circuit State

# src/myapp/api/routes.py
from myapp.services.payment import payment_breaker

@api_bp.route("/health/dependencies")
def dependency_health():
    """Report circuit breaker states for monitoring."""
    return jsonify({
        "payment_service": {
            "state": payment_breaker.current_state,
            "fail_count": payment_breaker.fail_counter,
        },
    })

Listener for Observability

# src/myapp/services/external.py
import pybreaker

class CircuitBreakerListener(pybreaker.CircuitBreakerListener):
    def state_change(self, cb, old_state, new_state):
        current_app.logger.warning(
            f"Circuit breaker {cb.name} changed from {old_state} to {new_state}"
        )
        # Send metric to monitoring system
        CIRCUIT_STATE.labels(breaker=cb.name, state=new_state.name).set(1)

    def failure(self, cb, exc):
        current_app.logger.error(f"Circuit breaker {cb.name} recorded failure: {exc}")

payment_breaker = pybreaker.CircuitBreaker(
    fail_max=3,
    reset_timeout=60,
    listeners=[CircuitBreakerListener()],
)

Feature Flags

Projects requiring controlled rollouts SHOULD implement feature flags.

Why Feature Flags

Feature flags enable gradual rollouts, A/B testing, and quick feature disabling without deployments. Flask-FeatureFlags30 provides simple configuration-based flags, while Unleash31 offers a full-featured management platform for larger teams.

Flask-FeatureFlags for Simple Cases

# src/myapp/__init__.py
from flask_featureflags import FeatureFlag

feature_flags = FeatureFlag()

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...

    app.config["FEATURE_FLAGS"] = {
        "new_checkout": True,
        "dark_mode": False,
        "beta_api": os.environ.get("ENABLE_BETA_API", "false").lower() == "true",
    }
    feature_flags.init_app(app)

    return app
# src/myapp/api/routes.py
from flask_featureflags import is_active

@api_bp.route("/checkout")
def checkout():
    if is_active("new_checkout"):
        return new_checkout_flow()
    return legacy_checkout_flow()
<!-- templates/base.html -->
{% if 'dark_mode' is active_feature %}
<link rel="stylesheet" href="/static/dark.css">
{% endif %}

Unleash for Enterprise Feature Management

Projects with complex flag requirements SHOULD use Unleash:

# src/myapp/extensions.py
from UnleashClient import UnleashClient

unleash = None

def init_unleash(app) -> UnleashClient:
    global unleash
    unleash = UnleashClient(
        url=app.config["UNLEASH_URL"],
        app_name="myapp",
        instance_id=app.config.get("INSTANCE_ID", "default"),
        custom_headers={"Authorization": app.config["UNLEASH_API_TOKEN"]},
    )
    unleash.initialize_client()
    return unleash
# src/myapp/__init__.py
from myapp.extensions import init_unleash

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    # ... configuration ...

    if app.config.get("UNLEASH_URL"):
        init_unleash(app)

    return app
# src/myapp/api/routes.py
from myapp.extensions import unleash

@api_bp.route("/feature")
def feature_endpoint():
    context = {
        "userId": str(current_user.id),
        "properties": {"plan": current_user.subscription_plan},
    }

    if unleash and unleash.is_enabled("premium_feature", context):
        return premium_feature_response()
    return standard_feature_response()

Custom Feature Flag Handler

Projects MAY implement custom handlers for database-backed or percentage-based flags:

# src/myapp/flags.py
import random
from flask import request
from flask_featureflags import FeatureFlag

def percentage_rollout(feature_name: str) -> bool | None:
    """Roll out feature to a percentage of users."""
    rollout_config = {
        "new_algorithm": 25,  # 25% of users
        "redesign": 50,       # 50% of users
    }
    if feature_name not in rollout_config:
        return None  # Let other handlers decide

    user_id = getattr(request, "user_id", random.random())
    return (hash(f"{feature_name}:{user_id}") % 100) < rollout_config[feature_name]

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    feature_flags = FeatureFlag(app)
    feature_flags.add_handler(percentage_rollout)
    return app

See Also

References

Footnotes

  1. Flask - Lightweight WSGI web application framework ↩

  2. Django - High-level Python web framework ↩

  3. FastAPI - Modern, fast web framework for building APIs ↩ ↩2

  4. Flask Blueprints - Modular applications ↩

  5. Flask-Login - User session management ↩ ↩2 ↩3 ↩4 ↩5

  6. Flask SECRET_KEY - Session signing key requirements ↩

  7. Flask-SQLAlchemy - SQLAlchemy support for Flask ↩

  8. Flask-Migrate - Database migrations with Alembic ↩

  9. Flask-WTF - WTForms integration ↩

  10. Flask-CORS - Cross-Origin Resource Sharing ↩

  11. Authlib - OAuth and OpenID Connect client/server library ↩

  12. PyJWT - JSON Web Token implementation in Python ↩

  13. Flask-Principal - Identity management for Flask ↩

  14. RFC 9700 - Best Current Practice for OAuth 2.0 Security ↩

  15. Authlib Flask OAuth client - Flask integration and PKCE configuration ↩ ↩2

  16. Flask-SocketIO - WebSocket support for Flask ↩ ↩2

  17. gevent-websocket - Archived WebSocket library for gevent ↩

  18. Flask-Profiler - Endpoint profiler with web UI ↩

  19. py-spy - Sampling profiler for Python programs ↩

  20. prometheus_client - Prometheus instrumentation library ↩

  21. Celery - Distributed task queue ↩

  22. RQ (Redis Queue) - Simple Python queuing with Redis ↩ ↩2

  23. Huey - Lightweight task queue for Python ↩

  24. Flask-RQ2 on PyPI - Release history for the unmaintained RQ integration ↩

  25. Quart - Async reimplementation of Flask ↩

  26. Flask-Caching - Caching support for Flask ↩

  27. Flask-Limiter - Rate limiting extension for Flask ↩

  28. Werkzeug ProxyFix - Trusting forwarded client addresses ↩

  29. pybreaker - Python circuit breaker implementation ↩ ↩2

  30. Flask-FeatureFlags - Feature flags for Flask ↩

  31. Unleash - Open-source feature management platform ↩