@robhowley/py-pit-skills 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/package.json +20 -0
- package/skills/alembic-migrations/SKILL.md +391 -0
- package/skills/click-cli/SKILL.md +204 -0
- package/skills/click-cli-linter/SKILL.md +192 -0
- package/skills/code-quality/SKILL.md +398 -0
- package/skills/dockerize-service/SKILL.md +280 -0
- package/skills/fastapi-errors/SKILL.md +319 -0
- package/skills/fastapi-init/SKILL.md +356 -0
- package/skills/pydantic-schemas/SKILL.md +500 -0
- package/skills/pytest-service/SKILL.md +216 -0
- package/skills/settings-config/SKILL.md +248 -0
- package/skills/sqlalchemy-models/SKILL.md +433 -0
- package/skills/uv/SKILL.md +310 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dockerize-service
|
|
3
|
+
description: Generate a local-development Docker Compose setup for an existing Python project. Produces a multi-stage Dockerfile, Compose file, env config, and dockerignore based on detected project signals.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: dockerize-service
|
|
7
|
+
|
|
8
|
+
## Core position
|
|
9
|
+
|
|
10
|
+
This skill produces a **local-development Docker Compose setup** that reflects the project as-is. It does not introduce backing services the repo doesn't already use, and it does not create a second Docker pattern if one already exists.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Trigger
|
|
15
|
+
|
|
16
|
+
Use this skill when the user wants to:
|
|
17
|
+
- Containerize an existing Python project
|
|
18
|
+
- Add Docker Compose to a project
|
|
19
|
+
- Add a Dockerfile to a project
|
|
20
|
+
- "Dockerize" a service
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Variables
|
|
25
|
+
|
|
26
|
+
- `{pkg_name}` — snake_case package name (matches the project directory and Python package)
|
|
27
|
+
- `{PKG_NAME}_` — env var prefix, only used if a pydantic-settings `env_prefix` is detected in the repo (e.g., `MY_SERVICE_`); otherwise plain names are used
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Step 1 — Inspect the repo
|
|
32
|
+
|
|
33
|
+
Inspect before asking. Work through each check in order and record findings.
|
|
34
|
+
|
|
35
|
+
**1. Check for existing Docker artifacts**
|
|
36
|
+
|
|
37
|
+
Look for `Dockerfile`, `docker-compose.yml`, `docker-compose.yaml`, `.dockerignore`. If any are present, extend them — do not create a competing setup. Note what was found.
|
|
38
|
+
|
|
39
|
+
**2. Check for repo tooling**
|
|
40
|
+
|
|
41
|
+
- `uv.lock` present → uv-native project; use uv base image in Dockerfile
|
|
42
|
+
- No `uv.lock` → pip-based project; use `python:3.12-slim` with `pip install`
|
|
43
|
+
|
|
44
|
+
**3. Check for service signals**
|
|
45
|
+
|
|
46
|
+
Inspect `pyproject.toml` dependencies and any settings/config files for:
|
|
47
|
+
- `sqlalchemy` + a non-sqlite database URL pattern, or `psycopg`, `psycopg2`, `asyncpg` in deps → Postgres
|
|
48
|
+
- `redis` in deps → Redis
|
|
49
|
+
- No signals → **app-only** (do not add Postgres as a default)
|
|
50
|
+
|
|
51
|
+
If Postgres is signaled, also note which driver is already present (`psycopg2-binary`, `psycopg[binary]`, `asyncpg`, etc.) — this determines what to add in Step 6.
|
|
52
|
+
|
|
53
|
+
**4. Check for an existing health endpoint**
|
|
54
|
+
|
|
55
|
+
Grep routes for `/health`, `/healthz`, `/ping`, `/api/v1/health`, or similar. Record the exact path if found.
|
|
56
|
+
|
|
57
|
+
**5. Detect app entrypoint**
|
|
58
|
+
|
|
59
|
+
Look for the ASGI `app` object in `{pkg_name}/main.py`, `{pkg_name}/app.py`, or `{pkg_name}/application.py`. Record the module path (e.g., `{pkg_name}.main:app`). Also check `[project.scripts]` in `pyproject.toml` for any uvicorn/gunicorn invocations. This becomes the `CMD` in the Dockerfile — default to `{pkg_name}.main:app` only if no entrypoint is found elsewhere.
|
|
60
|
+
|
|
61
|
+
**6. Detect env prefix**
|
|
62
|
+
|
|
63
|
+
Grep for `env_prefix` in `core/config.py`, `config.py`, `settings.py`, or similar. If a pydantic-settings prefix is found (e.g., `env_prefix="MY_SERVICE_"`), use it in `.env.compose`. If no prefix is detected, use plain variable names (`DATABASE_URL`, `REDIS_URL`, `DEBUG`) without a package prefix.
|
|
64
|
+
|
|
65
|
+
**7. Present inference summary**
|
|
66
|
+
|
|
67
|
+
Tell the user what was found and what will be generated. Ask for confirmation only if something is genuinely ambiguous (e.g., multiple config files with conflicting signals, or an existing `Dockerfile` with unclear extension points).
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Step 2 — Generate `Dockerfile`
|
|
72
|
+
|
|
73
|
+
Multi-stage build. Base image depends on repo tooling detected in Step 1.
|
|
74
|
+
|
|
75
|
+
**If `uv.lock` is present:**
|
|
76
|
+
|
|
77
|
+
```dockerfile
|
|
78
|
+
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
|
79
|
+
WORKDIR /app
|
|
80
|
+
COPY pyproject.toml uv.lock ./
|
|
81
|
+
RUN uv sync --frozen --no-dev --no-install-project
|
|
82
|
+
COPY {pkg_name}/ ./{pkg_name}/
|
|
83
|
+
RUN uv sync --frozen --no-dev
|
|
84
|
+
|
|
85
|
+
FROM python:3.12-slim
|
|
86
|
+
WORKDIR /app
|
|
87
|
+
COPY --from=builder /app/.venv /app/.venv
|
|
88
|
+
COPY --from=builder /app/{pkg_name} /app/{pkg_name}
|
|
89
|
+
ENV PATH="/app/.venv/bin:$PATH"
|
|
90
|
+
EXPOSE 8000
|
|
91
|
+
{HEALTHCHECK}
|
|
92
|
+
CMD ["/app/.venv/bin/uvicorn", "{detected_entrypoint|pkg_name.main:app}", "--host", "0.0.0.0", "--port", "8000"]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**If no `uv.lock`:**
|
|
96
|
+
|
|
97
|
+
```dockerfile
|
|
98
|
+
FROM python:3.12-slim AS builder
|
|
99
|
+
WORKDIR /app
|
|
100
|
+
COPY pyproject.toml ./
|
|
101
|
+
COPY {pkg_name}/ ./{pkg_name}/
|
|
102
|
+
RUN pip install --no-cache-dir --prefix=/install .
|
|
103
|
+
|
|
104
|
+
FROM python:3.12-slim
|
|
105
|
+
WORKDIR /app
|
|
106
|
+
COPY --from=builder /install /usr/local
|
|
107
|
+
EXPOSE 8000
|
|
108
|
+
{HEALTHCHECK}
|
|
109
|
+
CMD ["uvicorn", "{detected_entrypoint|pkg_name.main:app}", "--host", "0.0.0.0", "--port", "8000"]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**HEALTHCHECK selection:**
|
|
113
|
+
|
|
114
|
+
- If a health endpoint was detected in Step 1:
|
|
115
|
+
```dockerfile
|
|
116
|
+
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
|
|
117
|
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000{detected_path}')"
|
|
118
|
+
```
|
|
119
|
+
- If no health endpoint exists, use a TCP liveness check and note that a health endpoint should be added:
|
|
120
|
+
```dockerfile
|
|
121
|
+
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
|
|
122
|
+
CMD python -c "import socket; s=socket.socket(); s.settimeout(2); s.connect(('localhost', 8000)); s.close()"
|
|
123
|
+
```
|
|
124
|
+
> Note: No health route was found. This is a **liveness-only** check — it confirms the server is accepting TCP connections but does not verify app readiness. Add a `/health` endpoint for a proper readiness check.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Step 3 — Generate `docker-compose.yml`
|
|
129
|
+
|
|
130
|
+
Always include the `app` service. Add `db` (Postgres) and/or `cache` (Redis) only based on signals found in Step 1.
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
# Local development only — not a production deployment target
|
|
134
|
+
services:
|
|
135
|
+
app:
|
|
136
|
+
build: .
|
|
137
|
+
ports:
|
|
138
|
+
- "8000:8000"
|
|
139
|
+
env_file: .env.compose
|
|
140
|
+
depends_on:
|
|
141
|
+
db: # only if postgres signaled
|
|
142
|
+
condition: service_healthy
|
|
143
|
+
cache: # only if redis signaled
|
|
144
|
+
condition: service_healthy
|
|
145
|
+
restart: unless-stopped
|
|
146
|
+
|
|
147
|
+
db: # only if postgres signaled
|
|
148
|
+
image: postgres:16-alpine
|
|
149
|
+
environment:
|
|
150
|
+
POSTGRES_USER: postgres
|
|
151
|
+
POSTGRES_PASSWORD: postgres
|
|
152
|
+
POSTGRES_DB: {pkg_name}
|
|
153
|
+
volumes:
|
|
154
|
+
- postgres_data:/var/lib/postgresql/data
|
|
155
|
+
healthcheck:
|
|
156
|
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
157
|
+
interval: 5s
|
|
158
|
+
timeout: 5s
|
|
159
|
+
retries: 5
|
|
160
|
+
|
|
161
|
+
cache: # only if redis signaled
|
|
162
|
+
image: redis:7-alpine
|
|
163
|
+
healthcheck:
|
|
164
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
165
|
+
interval: 5s
|
|
166
|
+
timeout: 5s
|
|
167
|
+
retries: 5
|
|
168
|
+
|
|
169
|
+
volumes:
|
|
170
|
+
postgres_data: # only if postgres signaled
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Step 4 — Generate `.env.compose`
|
|
176
|
+
|
|
177
|
+
Environment variables for the running containers. Use the env prefix detected in Step 1 if a pydantic-settings prefix was found; otherwise use plain variable names. Generate **one** `.env.compose` — not both variants.
|
|
178
|
+
|
|
179
|
+
If prefix detected (e.g. `MY_SERVICE_`):
|
|
180
|
+
```
|
|
181
|
+
# docker compose environment — do not commit real secrets
|
|
182
|
+
MY_SERVICE_DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/{pkg_name} # only if postgres signaled
|
|
183
|
+
MY_SERVICE_REDIS_URL=redis://cache:6379/0 # only if redis signaled
|
|
184
|
+
MY_SERVICE_DEBUG=false
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
If no prefix detected:
|
|
188
|
+
```
|
|
189
|
+
# docker compose environment — do not commit real secrets
|
|
190
|
+
DATABASE_URL=postgresql+psycopg2://postgres:postgres@db:5432/{pkg_name} # only if postgres signaled
|
|
191
|
+
REDIS_URL=redis://cache:6379/0 # only if redis signaled
|
|
192
|
+
DEBUG=false
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Also create `.env.compose.example` with the same content — this file IS committed to version control as a template.
|
|
196
|
+
|
|
197
|
+
Add `.env.compose` to `.gitignore`:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
echo ".env.compose" >> .gitignore
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Step 5 — Generate `.dockerignore`
|
|
206
|
+
|
|
207
|
+
Skip if `.dockerignore` already exists and covers these patterns.
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
.venv/
|
|
211
|
+
__pycache__/
|
|
212
|
+
*.pyc
|
|
213
|
+
*.pyo
|
|
214
|
+
.pytest_cache/
|
|
215
|
+
.ruff_cache/
|
|
216
|
+
*.egg-info/
|
|
217
|
+
dist/
|
|
218
|
+
.env*
|
|
219
|
+
!.env.compose.example
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Step 6 — Update dependencies if needed
|
|
225
|
+
|
|
226
|
+
**Postgres driver** — only if Postgres was signaled and no postgres driver is already present in `pyproject.toml`:
|
|
227
|
+
|
|
228
|
+
Inspect the existing stack first:
|
|
229
|
+
- Async stack (uses `asyncpg` elsewhere, or `AsyncSession`) → add `asyncpg`
|
|
230
|
+
- Sync stack with modern psycopg → add `psycopg[binary]`
|
|
231
|
+
- Sync stack with legacy psycopg2 → add `psycopg2-binary`
|
|
232
|
+
- Do not introduce a second driver if one already exists
|
|
233
|
+
|
|
234
|
+
Follow the repo's dependency management workflow:
|
|
235
|
+
- `uv.lock` present → `uv add <driver>`
|
|
236
|
+
- No `uv.lock` → add `<driver>` to `[project.dependencies]` in `pyproject.toml` and follow the project's dependency-management workflow
|
|
237
|
+
|
|
238
|
+
**Redis** — only if Redis was signaled and `redis` is not already in deps:
|
|
239
|
+
- `uv.lock` present → `uv add redis`
|
|
240
|
+
- No `uv.lock` → add `redis` to `[project.dependencies]` in `pyproject.toml` and follow the project's dependency-management workflow
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## Step 7 — Verify
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
docker compose up --build -d
|
|
248
|
+
docker compose ps # all services healthy
|
|
249
|
+
curl http://localhost:8000{detected_health_path_or_omit_if_none}
|
|
250
|
+
docker compose down
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
If no health endpoint exists, omit the `curl` line and note that one should be added before moving to production.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Hard constraints
|
|
258
|
+
|
|
259
|
+
1. Always use multi-stage builds — keep the final image lean by excluding build tooling and intermediate artifacts
|
|
260
|
+
2. Never embed real credentials in `docker-compose.yml` — always use `env_file`
|
|
261
|
+
3. Always include a `HEALTHCHECK` in the Dockerfile and `healthcheck:` on backing services
|
|
262
|
+
4. Use `depends_on: condition: service_healthy` — not plain `depends_on`
|
|
263
|
+
5. Add `.env.compose` to `.gitignore`; provide `.env.compose.example` as a committed template
|
|
264
|
+
6. Never introduce a backing service (Postgres, Redis) unless the repo or user request shows it is needed — no signals → app-only
|
|
265
|
+
7. If Docker artifacts already exist, extend them; do not create a parallel competing setup
|
|
266
|
+
8. Match the project's existing dependency management workflow — do not assume uv if `uv.lock` is absent
|
|
267
|
+
9. Do not invent or hardcode a health endpoint path — detect from existing routes or use the TCP liveness fallback
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Completion checklist
|
|
272
|
+
|
|
273
|
+
- [ ] `Dockerfile` present, multi-stage, appropriate base image for repo tooling
|
|
274
|
+
- [ ] `docker-compose.yml` scoped to inferred services only; no uninferred backing services added
|
|
275
|
+
- [ ] `HEALTHCHECK` uses detected path or TCP liveness fallback — no invented path
|
|
276
|
+
- [ ] `.env.compose` present, `.gitignore` updated, `.env.compose.example` committed
|
|
277
|
+
- [ ] No existing Docker artifacts replaced without user confirmation
|
|
278
|
+
- [ ] No new backing services introduced without repo evidence or explicit user request
|
|
279
|
+
- [ ] No hardcoded credentials in `docker-compose.yml`
|
|
280
|
+
- [ ] `docker compose up --build` succeeds
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fastapi-errors
|
|
3
|
+
description: Opinionated error architecture for FastAPI services. Enforces a single internal exception hierarchy, constructor-based messages, consistent API error responses, and centralized logging for unexpected failures.
|
|
4
|
+
disable-model-invocation: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# fastapi-errors
|
|
8
|
+
|
|
9
|
+
Defines a **simple, consistent error architecture** for FastAPI backend services.
|
|
10
|
+
|
|
11
|
+
This skill standardizes the flow:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
service/domain failure
|
|
15
|
+
→ application exception
|
|
16
|
+
→ global FastAPI exception handler
|
|
17
|
+
→ consistent JSON response
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The goal is to prevent common FastAPI problems:
|
|
21
|
+
|
|
22
|
+
- `HTTPException` raised deep in service logic
|
|
23
|
+
- inconsistent error response formats
|
|
24
|
+
- duplicated status code logic
|
|
25
|
+
- domain failures implemented with builtin exceptions
|
|
26
|
+
- unexpected exceptions leaking poor diagnostics
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
# Apply this Skill When
|
|
31
|
+
|
|
32
|
+
Apply when the user:
|
|
33
|
+
|
|
34
|
+
- asks how to structure FastAPI error handling
|
|
35
|
+
- is adding domain exceptions
|
|
36
|
+
- is designing service-layer failures
|
|
37
|
+
- is implementing API error responses
|
|
38
|
+
- is building a new FastAPI service
|
|
39
|
+
- is adding new domain errors in an existing repo
|
|
40
|
+
|
|
41
|
+
Do **not** apply when:
|
|
42
|
+
|
|
43
|
+
- the task is unrelated to application error handling
|
|
44
|
+
- the user explicitly wants a different architecture
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
# Base Exception
|
|
49
|
+
|
|
50
|
+
The service defines one base exception for intentional application failures.
|
|
51
|
+
|
|
52
|
+
Preferred naming pattern:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
<PackageName>Error
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
BillingError
|
|
62
|
+
UserServiceError
|
|
63
|
+
InventoryError
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
If the package name cannot be determined, use:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
AppError
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Define all exceptions in a single module — `errors.py` or `exceptions.py` at the package root. Do not scatter them across feature files.
|
|
73
|
+
|
|
74
|
+
Example base implementation:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
class AppError(Exception):
|
|
78
|
+
status_code = 500
|
|
79
|
+
|
|
80
|
+
def __init__(self, message: str | None = None, **context):
|
|
81
|
+
self.message = message or "Unhandled application error"
|
|
82
|
+
self.context = context
|
|
83
|
+
super().__init__(self.message)
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str:
|
|
86
|
+
return self.message
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Key characteristics:
|
|
90
|
+
|
|
91
|
+
- default HTTP status = **500**
|
|
92
|
+
- default message provided in the constructor
|
|
93
|
+
- optional context data for logging
|
|
94
|
+
- subclasses override `status_code` or pass formatted messages
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
# Domain Exception Pattern
|
|
99
|
+
|
|
100
|
+
Application/domain errors should subclass the base error.
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
class UserNotFoundError(AppError):
|
|
106
|
+
status_code = 404
|
|
107
|
+
|
|
108
|
+
def __init__(self, user_id: int):
|
|
109
|
+
super().__init__(f"User {user_id} not found", user_id=user_id)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Example usage in service code:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
if user is None:
|
|
116
|
+
raise UserNotFoundError(user_id)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
# Global FastAPI Exception Handler
|
|
122
|
+
|
|
123
|
+
The API boundary converts internal exceptions into HTTP responses.
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
@app.exception_handler(AppError)
|
|
129
|
+
async def handle_app_error(request: Request, exc: AppError):
|
|
130
|
+
return JSONResponse(
|
|
131
|
+
status_code=exc.status_code,
|
|
132
|
+
content={"error": str(exc)},
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Response format is intentionally simple:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"error": "User 123 not found"
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
# Unexpected Exception Handling
|
|
147
|
+
|
|
148
|
+
Unexpected exceptions should be handled consistently.
|
|
149
|
+
|
|
150
|
+
Use a fallback handler for uncaught exceptions:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
@app.exception_handler(Exception)
|
|
154
|
+
async def handle_unexpected_error(request: Request, exc: Exception):
|
|
155
|
+
wrapped = AppError()
|
|
156
|
+
|
|
157
|
+
logger.exception(
|
|
158
|
+
"Unhandled exception",
|
|
159
|
+
extra={
|
|
160
|
+
"path": str(request.url.path),
|
|
161
|
+
"method": request.method,
|
|
162
|
+
"error_type": type(exc).__name__,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return JSONResponse(
|
|
167
|
+
status_code=wrapped.status_code,
|
|
168
|
+
content={"error": str(wrapped)},
|
|
169
|
+
)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
This does three things:
|
|
173
|
+
|
|
174
|
+
- preserves a stable client-facing response
|
|
175
|
+
- ensures unexpected failures are logged centrally
|
|
176
|
+
- treats uncaught exceptions consistently through the base application-error contract
|
|
177
|
+
|
|
178
|
+
Do not leak raw internal exception details to clients.
|
|
179
|
+
|
|
180
|
+
Log at minimum: request path, HTTP method, exception type, and correlation ID if the repo uses one. Follow existing structured logging conventions if present.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
# Existing Repository Strategy
|
|
185
|
+
|
|
186
|
+
In existing repositories, **inspect current error patterns before introducing new ones**.
|
|
187
|
+
|
|
188
|
+
Follow these rules:
|
|
189
|
+
|
|
190
|
+
- Look for an existing internal base exception.
|
|
191
|
+
- If one exists and is coherent, **extend it instead of creating a new base class**.
|
|
192
|
+
- Integrate with existing exception handlers when possible.
|
|
193
|
+
- Only introduce the recommended base-exception pattern if the repo lacks a clear contract or the user asks to refactor.
|
|
194
|
+
|
|
195
|
+
Do **not** create parallel exception hierarchies in a mature codebase.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
# Validation Errors
|
|
200
|
+
|
|
201
|
+
FastAPI raises `RequestValidationError` for malformed or invalid request bodies. This is framework-level — it does not subclass your app's base exception. Without a handler, FastAPI emits its default 422 response with a raw Pydantic error blob, which breaks response shape consistency.
|
|
202
|
+
|
|
203
|
+
Add a dedicated handler:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from fastapi.exceptions import RequestValidationError
|
|
207
|
+
|
|
208
|
+
@app.exception_handler(RequestValidationError)
|
|
209
|
+
async def handle_validation_error(request: Request, exc: RequestValidationError):
|
|
210
|
+
return JSONResponse(
|
|
211
|
+
status_code=422,
|
|
212
|
+
content={"error": "Invalid request data", "details": exc.errors()},
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Including `exc.errors()` in a `details` field is recommended — validation errors are client-fixable and surfacing them helps the caller correct the request.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
# Auth Errors
|
|
221
|
+
|
|
222
|
+
Auth handling involves two distinct cases. Don't conflate them.
|
|
223
|
+
|
|
224
|
+
**FastAPI security dependencies** (`HTTPBearer`, `OAuth2PasswordBearer`, `Depends(security_scheme)`, etc.) raise `HTTPException` internally. This is expected framework behavior — do not fight it or try to wrap it in domain exceptions.
|
|
225
|
+
|
|
226
|
+
**Custom auth logic** (token validation, permission checks) belongs in service code and should use domain exceptions:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
class UnauthenticatedError(AppError):
|
|
230
|
+
status_code = 401
|
|
231
|
+
|
|
232
|
+
def __init__(self):
|
|
233
|
+
super().__init__("Authentication required")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class UnauthorizedError(AppError):
|
|
237
|
+
status_code = 403
|
|
238
|
+
|
|
239
|
+
def __init__(self, action: str | None = None):
|
|
240
|
+
msg = f"Not authorized to {action}" if action else "Not authorized"
|
|
241
|
+
super().__init__(msg)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
These flow through the global `AppError` handler like any other domain exception.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
# Error Codes (Optional)
|
|
249
|
+
|
|
250
|
+
For APIs consumed by clients that need to branch on error type — public APIs, client SDKs, multi-error workflows — a machine-readable `code` field is useful. Skip this for internal services or simple CRUD APIs where string parsing is acceptable.
|
|
251
|
+
|
|
252
|
+
Pattern: add an optional `code` class attribute to the base exception; domain subclasses override it.
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
class AppError(Exception):
|
|
256
|
+
status_code = 500
|
|
257
|
+
code: str | None = None # add this
|
|
258
|
+
...
|
|
259
|
+
|
|
260
|
+
class UserNotFoundError(AppError):
|
|
261
|
+
status_code = 404
|
|
262
|
+
code = "user_not_found" # subclasses set a value
|
|
263
|
+
...
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Update the global handler to include `code` when present:
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
@app.exception_handler(AppError)
|
|
270
|
+
async def handle_app_error(request: Request, exc: AppError):
|
|
271
|
+
content = {"error": str(exc)}
|
|
272
|
+
if exc.code:
|
|
273
|
+
content["code"] = exc.code
|
|
274
|
+
return JSONResponse(status_code=exc.status_code, content=content)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Response shape when `code` is set:
|
|
278
|
+
|
|
279
|
+
```json
|
|
280
|
+
{
|
|
281
|
+
"error": "User 123 not found",
|
|
282
|
+
"code": "user_not_found"
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
# Hard Rules
|
|
289
|
+
|
|
290
|
+
**MUST**
|
|
291
|
+
|
|
292
|
+
- Define a single internal base exception for application failures.
|
|
293
|
+
- Subclass that base exception for domain errors.
|
|
294
|
+
- Declare `status_code` on exception classes.
|
|
295
|
+
- Handle the internal exception hierarchy in one global FastAPI handler.
|
|
296
|
+
- Return a consistent JSON error response.
|
|
297
|
+
- Log unexpected exceptions centrally in the fallback handler.
|
|
298
|
+
- Add a `RequestValidationError` handler when response shape consistency matters.
|
|
299
|
+
|
|
300
|
+
**MUST NOT**
|
|
301
|
+
|
|
302
|
+
- Raise `HTTPException` in service or domain code. Exception: `HTTPException` raised internally by FastAPI security dependencies (`HTTPBearer`, `OAuth2PasswordBearer`, etc.) is acceptable — that is the framework's designed behavior.
|
|
303
|
+
- Use builtin exceptions (`ValueError`, `RuntimeError`, etc.) for intentional domain failures.
|
|
304
|
+
- Invent new error response formats per endpoint.
|
|
305
|
+
- Duplicate HTTP status logic outside the exception classes.
|
|
306
|
+
- Introduce a second application error hierarchy when one already exists.
|
|
307
|
+
- Leak raw internal exception messages or stack traces to clients.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
# Outcome
|
|
312
|
+
|
|
313
|
+
Applying this skill results in:
|
|
314
|
+
|
|
315
|
+
- one coherent application error hierarchy
|
|
316
|
+
- clear separation between service failures and HTTP responses
|
|
317
|
+
- consistent API error responses
|
|
318
|
+
- centralized logging for true unexpected failures
|
|
319
|
+
- predictable error handling across the entire service
|