claude-code-pilot 3.2.0 → 3.3.0
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/CHANGELOG.md +57 -0
- package/README.md +14 -9
- package/bin/install.js +113 -15
- package/manifest.json +18 -3
- package/package.json +3 -2
- package/src/agents/django-build-resolver.md +252 -0
- package/src/agents/django-reviewer.md +169 -0
- package/src/agents/fastapi-reviewer.md +79 -0
- package/src/agents/fsharp-reviewer.md +109 -0
- package/src/agents/swift-build-resolver.md +170 -0
- package/src/agents/swift-reviewer.md +116 -0
- package/src/commands/ccp/cost-report.md +107 -0
- package/src/commands/ccp/intel.md +3 -3
- package/src/commands/ccp/mvp-phase.md +45 -0
- package/src/commands/ccp/plan-prd.md +160 -0
- package/src/commands/ccp/pr-ecc.md +184 -0
- package/src/commands/ccp/security-scan.md +74 -0
- package/src/hooks/ccp-bash-hook-dispatcher.js +96 -0
- package/src/hooks/ccp-context-monitor.js +23 -0
- package/src/hooks/ccp-doc-file-warning.js +93 -0
- package/src/hooks/ccp-pre-bash-dispatcher.js +24 -0
- package/src/hooks/ccp-write-gateguard.js +868 -0
- package/src/lib/project-detect.js +0 -2
- package/src/lib/shell-substitution.js +499 -0
- package/src/pilot/references/execute-mvp-tdd.md +81 -0
- package/src/pilot/references/mvp-concepts.md +49 -0
- package/src/pilot/references/planner-graphify-auto-update.md +67 -0
- package/src/pilot/references/planner-human-verify-mode.md +57 -0
- package/src/pilot/references/planner-mvp-mode.md +53 -0
- package/src/pilot/references/skeleton-template.md +48 -0
- package/src/pilot/references/spidr-splitting.md +69 -0
- package/src/pilot/references/user-story-template.md +58 -0
- package/src/pilot/references/verify-mvp-mode.md +85 -0
- package/src/pilot/references/worktree-path-safety.md +89 -0
- package/src/pilot/workflows/help.md +5 -0
- package/src/pilot/workflows/mvp-phase.md +199 -0
- package/src/skills/agent-architecture-audit/SKILL.md +256 -0
- package/src/skills/agent-harness-design/SKILL.md +73 -0
- package/src/skills/angular-developer/SKILL.md +154 -0
- package/src/skills/angular-developer/references/angular-animations.md +160 -0
- package/src/skills/angular-developer/references/angular-aria.md +410 -0
- package/src/skills/angular-developer/references/cli.md +86 -0
- package/src/skills/angular-developer/references/component-harnesses.md +59 -0
- package/src/skills/angular-developer/references/component-styling.md +91 -0
- package/src/skills/angular-developer/references/components.md +117 -0
- package/src/skills/angular-developer/references/creating-services.md +97 -0
- package/src/skills/angular-developer/references/data-resolvers.md +69 -0
- package/src/skills/angular-developer/references/define-routes.md +67 -0
- package/src/skills/angular-developer/references/defining-providers.md +72 -0
- package/src/skills/angular-developer/references/di-fundamentals.md +120 -0
- package/src/skills/angular-developer/references/e2e-testing.md +56 -0
- package/src/skills/angular-developer/references/effects.md +83 -0
- package/src/skills/angular-developer/references/hierarchical-injectors.md +43 -0
- package/src/skills/angular-developer/references/host-elements.md +80 -0
- package/src/skills/angular-developer/references/injection-context.md +63 -0
- package/src/skills/angular-developer/references/inputs.md +101 -0
- package/src/skills/angular-developer/references/linked-signal.md +59 -0
- package/src/skills/angular-developer/references/loading-strategies.md +61 -0
- package/src/skills/angular-developer/references/mcp.md +108 -0
- package/src/skills/angular-developer/references/navigate-to-routes.md +69 -0
- package/src/skills/angular-developer/references/outputs.md +86 -0
- package/src/skills/angular-developer/references/reactive-forms.md +122 -0
- package/src/skills/angular-developer/references/rendering-strategies.md +44 -0
- package/src/skills/angular-developer/references/resource.md +77 -0
- package/src/skills/angular-developer/references/route-animations.md +56 -0
- package/src/skills/angular-developer/references/route-guards.md +52 -0
- package/src/skills/angular-developer/references/router-lifecycle.md +45 -0
- package/src/skills/angular-developer/references/router-testing.md +87 -0
- package/src/skills/angular-developer/references/show-routes-with-outlets.md +68 -0
- package/src/skills/angular-developer/references/signal-forms.md +795 -0
- package/src/skills/angular-developer/references/signals-overview.md +94 -0
- package/src/skills/angular-developer/references/tailwind-css.md +69 -0
- package/src/skills/angular-developer/references/template-driven-forms.md +114 -0
- package/src/skills/angular-developer/references/testing-fundamentals.md +65 -0
- package/src/skills/error-handling/SKILL.md +376 -0
- package/src/skills/fastapi-patterns/SKILL.md +327 -0
- package/src/skills/flox-environments/SKILL.md +496 -0
- package/src/skills/fsharp-testing/SKILL.md +280 -0
- package/src/skills/ios-icon-gen/SKILL.md +157 -0
- package/src/skills/ios-icon-gen/scripts/generate_icons.swift +258 -0
- package/src/skills/ios-icon-gen/scripts/iconify_gen.sh +235 -0
- package/src/skills/make-interfaces-feel-better/SKILL.md +151 -0
- package/src/skills/mysql-patterns/SKILL.md +412 -0
- package/src/skills/plan-orchestrate/SKILL.md +220 -0
- package/src/skills/prisma-patterns/SKILL.md +371 -0
- package/src/skills/production-audit/SKILL.md +206 -0
- package/src/skills/security-scan/references/agentshield-policy-exception/candidate-playbook.md +49 -0
- package/src/skills/security-scan/references/agentshield-policy-exception/report.json +35 -0
- package/src/skills/security-scan/references/agentshield-policy-exception/scenario.json +62 -0
- package/src/skills/security-scan/references/agentshield-policy-exception/trace.json +45 -0
- package/src/skills/security-scan/references/agentshield-policy-exception/verifier-result.json +35 -0
- package/src/skills/vite-patterns/SKILL.md +449 -0
- package/src/skills/windows-desktop-e2e/SKILL.md +887 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: error-handling
|
|
3
|
+
description: Patterns for robust error handling across TypeScript, Python, and Go. Covers typed errors, error boundaries, retries, circuit breakers, and user-facing error messages.
|
|
4
|
+
origin: ECC
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Error Handling Patterns
|
|
8
|
+
|
|
9
|
+
Consistent, robust error handling patterns for production applications.
|
|
10
|
+
|
|
11
|
+
## When to Activate
|
|
12
|
+
|
|
13
|
+
- Designing error types or exception hierarchies for a new module or service
|
|
14
|
+
- Adding retry logic or circuit breakers for unreliable external dependencies
|
|
15
|
+
- Reviewing API endpoints for missing error handling
|
|
16
|
+
- Implementing user-facing error messages and feedback
|
|
17
|
+
- Debugging cascading failures or silent error swallowing
|
|
18
|
+
|
|
19
|
+
## Core Principles
|
|
20
|
+
|
|
21
|
+
1. **Fail fast and loudly** — surface errors at the boundary where they occur; don't bury them
|
|
22
|
+
2. **Typed errors over string messages** — errors are first-class values with structure
|
|
23
|
+
3. **User messages ≠ developer messages** — show friendly text to users, log full context server-side
|
|
24
|
+
4. **Never swallow errors silently** — every `catch` block must either handle, re-throw, or log
|
|
25
|
+
5. **Errors are part of your API contract** — document every error code a client may receive
|
|
26
|
+
|
|
27
|
+
## TypeScript / JavaScript
|
|
28
|
+
|
|
29
|
+
### Typed Error Classes
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// Define an error hierarchy for your domain
|
|
33
|
+
export class AppError extends Error {
|
|
34
|
+
constructor(
|
|
35
|
+
message: string,
|
|
36
|
+
public readonly code: string,
|
|
37
|
+
public readonly statusCode: number = 500,
|
|
38
|
+
public readonly details?: unknown,
|
|
39
|
+
) {
|
|
40
|
+
super(message)
|
|
41
|
+
this.name = this.constructor.name
|
|
42
|
+
// Maintain correct prototype chain in transpiled ES5 JavaScript.
|
|
43
|
+
// Required for `instanceof` checks (e.g., `error instanceof NotFoundError`)
|
|
44
|
+
// to work correctly when extending the built-in Error class.
|
|
45
|
+
Object.setPrototypeOf(this, new.target.prototype)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class NotFoundError extends AppError {
|
|
50
|
+
constructor(resource: string, id: string) {
|
|
51
|
+
super(`${resource} not found: ${id}`, 'NOT_FOUND', 404)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class ValidationError extends AppError {
|
|
56
|
+
constructor(message: string, details: { field: string; message: string }[]) {
|
|
57
|
+
super(message, 'VALIDATION_ERROR', 422, details)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class UnauthorizedError extends AppError {
|
|
62
|
+
constructor(reason = 'Authentication required') {
|
|
63
|
+
super(reason, 'UNAUTHORIZED', 401)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class RateLimitError extends AppError {
|
|
68
|
+
constructor(public readonly retryAfterMs: number) {
|
|
69
|
+
super('Rate limit exceeded', 'RATE_LIMITED', 429)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Result Pattern (no-throw style)
|
|
75
|
+
|
|
76
|
+
For operations where failure is expected and common (parsing, external calls):
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
type Result<T, E = AppError> =
|
|
80
|
+
| { ok: true; value: T }
|
|
81
|
+
| { ok: false; error: E }
|
|
82
|
+
|
|
83
|
+
function ok<T>(value: T): Result<T> {
|
|
84
|
+
return { ok: true, value }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function err<E>(error: E): Result<never, E> {
|
|
88
|
+
return { ok: false, error }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Usage
|
|
92
|
+
async function fetchUser(id: string): Promise<Result<User>> {
|
|
93
|
+
try {
|
|
94
|
+
const user = await db.users.findUnique({ where: { id } })
|
|
95
|
+
if (!user) return err(new NotFoundError('User', id))
|
|
96
|
+
return ok(user)
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return err(new AppError('Database error', 'DB_ERROR'))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result = await fetchUser('abc-123')
|
|
103
|
+
if (!result.ok) {
|
|
104
|
+
// TypeScript knows result.error here
|
|
105
|
+
logger.error('Failed to fetch user', { error: result.error })
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
// TypeScript knows result.value here
|
|
109
|
+
console.log(result.value.email)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### API Error Handler (Next.js / Express)
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
116
|
+
|
|
117
|
+
function handleApiError(error: unknown): NextResponse {
|
|
118
|
+
// Known application error
|
|
119
|
+
if (error instanceof AppError) {
|
|
120
|
+
return NextResponse.json(
|
|
121
|
+
{
|
|
122
|
+
error: {
|
|
123
|
+
code: error.code,
|
|
124
|
+
message: error.message,
|
|
125
|
+
...(error.details ? { details: error.details } : {}),
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{ status: error.statusCode },
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Zod validation error
|
|
133
|
+
if (error instanceof z.ZodError) {
|
|
134
|
+
return NextResponse.json(
|
|
135
|
+
{
|
|
136
|
+
error: {
|
|
137
|
+
code: 'VALIDATION_ERROR',
|
|
138
|
+
message: 'Request validation failed',
|
|
139
|
+
details: error.issues.map(i => ({
|
|
140
|
+
field: i.path.join('.'),
|
|
141
|
+
message: i.message,
|
|
142
|
+
})),
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{ status: 422 },
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Unexpected error — log details, return generic message
|
|
150
|
+
console.error('Unexpected error:', error)
|
|
151
|
+
return NextResponse.json(
|
|
152
|
+
{ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } },
|
|
153
|
+
{ status: 500 },
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function POST(req: NextRequest) {
|
|
158
|
+
try {
|
|
159
|
+
// ... handler logic
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return handleApiError(error)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### React Error Boundary
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
import { Component, ErrorInfo, ReactNode } from 'react'
|
|
170
|
+
|
|
171
|
+
interface Props {
|
|
172
|
+
fallback: ReactNode
|
|
173
|
+
onError?: (error: Error, info: ErrorInfo) => void
|
|
174
|
+
children: ReactNode
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface State {
|
|
178
|
+
hasError: boolean
|
|
179
|
+
error: Error | null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
183
|
+
state: State = { hasError: false, error: null }
|
|
184
|
+
|
|
185
|
+
static getDerivedStateFromError(error: Error): State {
|
|
186
|
+
return { hasError: true, error }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
190
|
+
this.props.onError?.(error, info)
|
|
191
|
+
console.error('Unhandled React error:', error, info)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
render() {
|
|
195
|
+
if (this.state.hasError) return this.props.fallback
|
|
196
|
+
return this.props.children
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Usage
|
|
201
|
+
<ErrorBoundary fallback={<p>Something went wrong. Please refresh.</p>}>
|
|
202
|
+
<MyComponent />
|
|
203
|
+
</ErrorBoundary>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Python
|
|
207
|
+
|
|
208
|
+
### Custom Exception Hierarchy
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
class AppError(Exception):
|
|
212
|
+
"""Base application error."""
|
|
213
|
+
def __init__(self, message: str, code: str, status_code: int = 500):
|
|
214
|
+
super().__init__(message)
|
|
215
|
+
self.code = code
|
|
216
|
+
self.status_code = status_code
|
|
217
|
+
|
|
218
|
+
class NotFoundError(AppError):
|
|
219
|
+
def __init__(self, resource: str, id: str):
|
|
220
|
+
super().__init__(f"{resource} not found: {id}", "NOT_FOUND", 404)
|
|
221
|
+
|
|
222
|
+
class ValidationError(AppError):
|
|
223
|
+
def __init__(self, message: str, details: list[dict] | None = None):
|
|
224
|
+
super().__init__(message, "VALIDATION_ERROR", 422)
|
|
225
|
+
self.details = details or []
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### FastAPI Global Exception Handler
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
from fastapi import FastAPI, Request
|
|
232
|
+
from fastapi.responses import JSONResponse
|
|
233
|
+
|
|
234
|
+
app = FastAPI()
|
|
235
|
+
|
|
236
|
+
@app.exception_handler(AppError)
|
|
237
|
+
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
|
238
|
+
return JSONResponse(
|
|
239
|
+
status_code=exc.status_code,
|
|
240
|
+
content={"error": {"code": exc.code, "message": str(exc)}},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
@app.exception_handler(Exception)
|
|
244
|
+
async def generic_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
245
|
+
# Log full details, return generic message
|
|
246
|
+
logger.exception("Unexpected error", exc_info=exc)
|
|
247
|
+
return JSONResponse(
|
|
248
|
+
status_code=500,
|
|
249
|
+
content={"error": {"code": "INTERNAL_ERROR", "message": "An unexpected error occurred"}},
|
|
250
|
+
)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Go
|
|
254
|
+
|
|
255
|
+
### Sentinel Errors and Error Wrapping
|
|
256
|
+
|
|
257
|
+
```go
|
|
258
|
+
package domain
|
|
259
|
+
|
|
260
|
+
import "errors"
|
|
261
|
+
|
|
262
|
+
// Sentinel errors for type-checking
|
|
263
|
+
var (
|
|
264
|
+
ErrNotFound = errors.New("not found")
|
|
265
|
+
ErrUnauthorized = errors.New("unauthorized")
|
|
266
|
+
ErrConflict = errors.New("conflict")
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
// Wrap errors with context — never lose the original
|
|
270
|
+
func (r *UserRepository) FindByID(ctx context.Context, id string) (*User, error) {
|
|
271
|
+
user, err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE id = $1", id)
|
|
272
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
273
|
+
return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
|
|
274
|
+
}
|
|
275
|
+
if err != nil {
|
|
276
|
+
return nil, fmt.Errorf("querying user %s: %w", id, err)
|
|
277
|
+
}
|
|
278
|
+
return user, nil
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// At the handler level, unwrap to determine response
|
|
282
|
+
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
|
|
283
|
+
user, err := h.service.GetUser(r.Context(), chi.URLParam(r, "id"))
|
|
284
|
+
if err != nil {
|
|
285
|
+
switch {
|
|
286
|
+
case errors.Is(err, domain.ErrNotFound):
|
|
287
|
+
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
288
|
+
case errors.Is(err, domain.ErrUnauthorized):
|
|
289
|
+
writeError(w, http.StatusForbidden, "forbidden", "Access denied")
|
|
290
|
+
default:
|
|
291
|
+
slog.Error("unexpected error", "err", err)
|
|
292
|
+
writeError(w, http.StatusInternalServerError, "internal_error", "An unexpected error occurred")
|
|
293
|
+
}
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
writeJSON(w, http.StatusOK, user)
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Retry with Exponential Backoff
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
interface RetryOptions {
|
|
304
|
+
maxAttempts?: number
|
|
305
|
+
baseDelayMs?: number
|
|
306
|
+
maxDelayMs?: number
|
|
307
|
+
retryIf?: (error: unknown) => boolean
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function withRetry<T>(
|
|
311
|
+
fn: () => Promise<T>,
|
|
312
|
+
options: RetryOptions = {},
|
|
313
|
+
): Promise<T> {
|
|
314
|
+
const {
|
|
315
|
+
maxAttempts = 3,
|
|
316
|
+
baseDelayMs = 500,
|
|
317
|
+
maxDelayMs = 10_000,
|
|
318
|
+
retryIf = () => true,
|
|
319
|
+
} = options
|
|
320
|
+
|
|
321
|
+
let lastError: unknown
|
|
322
|
+
|
|
323
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
324
|
+
try {
|
|
325
|
+
return await fn()
|
|
326
|
+
} catch (error) {
|
|
327
|
+
lastError = error
|
|
328
|
+
if (attempt === maxAttempts || !retryIf(error)) throw error
|
|
329
|
+
|
|
330
|
+
const jitter = Math.random() * baseDelayMs
|
|
331
|
+
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1) + jitter, maxDelayMs)
|
|
332
|
+
await new Promise(resolve => setTimeout(resolve, delay))
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
throw lastError
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Usage: retry transient network errors, not 4xx
|
|
340
|
+
const data = await withRetry(() => fetch('/api/data').then(r => r.json()), {
|
|
341
|
+
maxAttempts: 3,
|
|
342
|
+
retryIf: (error) => !(error instanceof AppError && error.statusCode < 500),
|
|
343
|
+
})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## User-Facing Error Messages
|
|
347
|
+
|
|
348
|
+
Map error codes to human-readable messages. Keep technical details out of user-visible text.
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
const USER_ERROR_MESSAGES: Record<string, string> = {
|
|
352
|
+
NOT_FOUND: 'The requested item could not be found.',
|
|
353
|
+
UNAUTHORIZED: 'Please sign in to continue.',
|
|
354
|
+
FORBIDDEN: "You don't have permission to do that.",
|
|
355
|
+
VALIDATION_ERROR: 'Please check your input and try again.',
|
|
356
|
+
RATE_LIMITED: 'Too many requests. Please wait a moment and try again.',
|
|
357
|
+
INTERNAL_ERROR: 'Something went wrong on our end. Please try again later.',
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function getUserMessage(code: string): string {
|
|
361
|
+
return USER_ERROR_MESSAGES[code] ?? USER_ERROR_MESSAGES.INTERNAL_ERROR
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## Error Handling Checklist
|
|
366
|
+
|
|
367
|
+
Before merging any code that touches error handling:
|
|
368
|
+
|
|
369
|
+
- [ ] Every `catch` block handles, re-throws, or logs — no silent swallowing
|
|
370
|
+
- [ ] API errors follow the standard envelope `{ error: { code, message } }`
|
|
371
|
+
- [ ] User-facing messages contain no stack traces or internal details
|
|
372
|
+
- [ ] Full error context is logged server-side
|
|
373
|
+
- [ ] Custom error classes extend a base `AppError` with a `code` field
|
|
374
|
+
- [ ] Async functions surface errors to callers — no fire-and-forget without fallback
|
|
375
|
+
- [ ] Retry logic only retries retriable errors (not 4xx client errors)
|
|
376
|
+
- [ ] React components are wrapped in `ErrorBoundary` for rendering errors
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fastapi-patterns
|
|
3
|
+
description: FastAPI patterns for async APIs, dependency injection, Pydantic request and response models, OpenAPI docs, tests, security, and production readiness.
|
|
4
|
+
origin: community
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# FastAPI Patterns
|
|
8
|
+
|
|
9
|
+
Production-oriented patterns for FastAPI services.
|
|
10
|
+
|
|
11
|
+
## When to Use
|
|
12
|
+
|
|
13
|
+
- Building or reviewing a FastAPI app.
|
|
14
|
+
- Splitting routers, schemas, dependencies, and database access.
|
|
15
|
+
- Writing async endpoints that call a database or external service.
|
|
16
|
+
- Adding authentication, authorization, OpenAPI docs, tests, or deployment settings.
|
|
17
|
+
- Checking a FastAPI PR for copy-pasteable examples and production risks.
|
|
18
|
+
|
|
19
|
+
## How It Works
|
|
20
|
+
|
|
21
|
+
Treat the FastAPI app as a thin HTTP layer over explicit dependencies and service code:
|
|
22
|
+
|
|
23
|
+
- `main.py` owns app construction, middleware, exception handlers, and router registration.
|
|
24
|
+
- `schemas/` owns Pydantic request and response models.
|
|
25
|
+
- `dependencies.py` owns database, auth, pagination, and request-scoped dependencies.
|
|
26
|
+
- `services/` or `crud/` owns business and persistence operations.
|
|
27
|
+
- `tests/` overrides dependencies instead of opening production resources.
|
|
28
|
+
|
|
29
|
+
Prefer small routers and explicit `response_model` declarations. Keep raw ORM objects, secrets, and framework globals out of response schemas.
|
|
30
|
+
|
|
31
|
+
## Project Layout
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
app/
|
|
35
|
+
|-- main.py
|
|
36
|
+
|-- config.py
|
|
37
|
+
|-- dependencies.py
|
|
38
|
+
|-- exceptions.py
|
|
39
|
+
|-- api/
|
|
40
|
+
| `-- routes/
|
|
41
|
+
| |-- users.py
|
|
42
|
+
| `-- health.py
|
|
43
|
+
|-- core/
|
|
44
|
+
| |-- security.py
|
|
45
|
+
| `-- middleware.py
|
|
46
|
+
|-- db/
|
|
47
|
+
| |-- session.py
|
|
48
|
+
| `-- crud.py
|
|
49
|
+
|-- models/
|
|
50
|
+
|-- schemas/
|
|
51
|
+
`-- tests/
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Application Factory
|
|
55
|
+
|
|
56
|
+
Use a factory so tests and workers can build the app with controlled settings.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from contextlib import asynccontextmanager
|
|
60
|
+
|
|
61
|
+
from fastapi import FastAPI
|
|
62
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
63
|
+
|
|
64
|
+
from app.api.routes import health, users
|
|
65
|
+
from app.config import settings
|
|
66
|
+
from app.db.session import close_db, init_db
|
|
67
|
+
from app.exceptions import register_exception_handlers
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@asynccontextmanager
|
|
71
|
+
async def lifespan(app: FastAPI):
|
|
72
|
+
await init_db()
|
|
73
|
+
yield
|
|
74
|
+
await close_db()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_app() -> FastAPI:
|
|
78
|
+
app = FastAPI(
|
|
79
|
+
title=settings.api_title,
|
|
80
|
+
version=settings.api_version,
|
|
81
|
+
lifespan=lifespan,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
app.add_middleware(
|
|
85
|
+
CORSMiddleware,
|
|
86
|
+
allow_origins=settings.cors_origins,
|
|
87
|
+
allow_credentials=bool(settings.cors_origins),
|
|
88
|
+
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
89
|
+
allow_headers=["Authorization", "Content-Type"],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
register_exception_handlers(app)
|
|
93
|
+
app.include_router(health.router, prefix="/health", tags=["health"])
|
|
94
|
+
app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
|
|
95
|
+
return app
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
app = create_app()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Do not use `allow_origins=["*"]` with `allow_credentials=True`; browsers reject that combination and Starlette disallows it for credentialed requests.
|
|
102
|
+
|
|
103
|
+
## Pydantic Schemas
|
|
104
|
+
|
|
105
|
+
Keep request, update, and response models separate.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from datetime import datetime
|
|
109
|
+
from typing import Annotated
|
|
110
|
+
from uuid import UUID
|
|
111
|
+
|
|
112
|
+
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class UserBase(BaseModel):
|
|
116
|
+
email: EmailStr
|
|
117
|
+
full_name: Annotated[str, Field(min_length=1, max_length=100)]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class UserCreate(UserBase):
|
|
121
|
+
password: Annotated[str, Field(min_length=12, max_length=128)]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class UserUpdate(BaseModel):
|
|
125
|
+
email: EmailStr | None = None
|
|
126
|
+
full_name: Annotated[str | None, Field(min_length=1, max_length=100)] = None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class UserResponse(UserBase):
|
|
130
|
+
model_config = ConfigDict(from_attributes=True)
|
|
131
|
+
|
|
132
|
+
id: UUID
|
|
133
|
+
created_at: datetime
|
|
134
|
+
updated_at: datetime
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Response models must never include password hashes, access tokens, refresh tokens, or internal authorization state.
|
|
138
|
+
|
|
139
|
+
## Dependencies
|
|
140
|
+
|
|
141
|
+
Use dependency injection for request-scoped resources.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from collections.abc import AsyncIterator
|
|
145
|
+
from uuid import UUID
|
|
146
|
+
|
|
147
|
+
from fastapi import Depends, HTTPException, status
|
|
148
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
149
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
150
|
+
|
|
151
|
+
from app.core.security import decode_token
|
|
152
|
+
from app.db.session import session_factory
|
|
153
|
+
from app.models.user import User
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def get_db() -> AsyncIterator[AsyncSession]:
|
|
160
|
+
async with session_factory() as session:
|
|
161
|
+
try:
|
|
162
|
+
yield session
|
|
163
|
+
await session.commit()
|
|
164
|
+
except Exception:
|
|
165
|
+
await session.rollback()
|
|
166
|
+
raise
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def get_current_user(
|
|
170
|
+
token: str = Depends(oauth2_scheme),
|
|
171
|
+
db: AsyncSession = Depends(get_db),
|
|
172
|
+
) -> User:
|
|
173
|
+
payload = decode_token(token)
|
|
174
|
+
user_id = UUID(payload["sub"])
|
|
175
|
+
user = await db.get(User, user_id)
|
|
176
|
+
if user is None:
|
|
177
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
|
178
|
+
return user
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Avoid creating sessions, clients, or credentials inline inside route handlers.
|
|
182
|
+
|
|
183
|
+
## Async Endpoints
|
|
184
|
+
|
|
185
|
+
Keep route handlers async when they perform I/O, and use async libraries inside them.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from fastapi import APIRouter, Depends, Query
|
|
189
|
+
from sqlalchemy import select
|
|
190
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
191
|
+
|
|
192
|
+
from app.dependencies import get_current_user, get_db
|
|
193
|
+
from app.models.user import User
|
|
194
|
+
from app.schemas.user import UserResponse
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
router = APIRouter()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@router.get("/", response_model=list[UserResponse])
|
|
201
|
+
async def list_users(
|
|
202
|
+
limit: int = Query(default=50, ge=1, le=100),
|
|
203
|
+
offset: int = Query(default=0, ge=0),
|
|
204
|
+
db: AsyncSession = Depends(get_db),
|
|
205
|
+
current_user: User = Depends(get_current_user),
|
|
206
|
+
):
|
|
207
|
+
result = await db.execute(
|
|
208
|
+
select(User).order_by(User.created_at.desc()).limit(limit).offset(offset)
|
|
209
|
+
)
|
|
210
|
+
return result.scalars().all()
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Use `httpx.AsyncClient` for external HTTP calls from async handlers. Do not call `requests` in an async route.
|
|
214
|
+
|
|
215
|
+
## Error Handling
|
|
216
|
+
|
|
217
|
+
Centralize domain exceptions and keep response shapes stable.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from fastapi import FastAPI, Request
|
|
221
|
+
from fastapi.responses import JSONResponse
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class ApiError(Exception):
|
|
225
|
+
def __init__(self, status_code: int, code: str, message: str):
|
|
226
|
+
self.status_code = status_code
|
|
227
|
+
self.code = code
|
|
228
|
+
self.message = message
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def register_exception_handlers(app: FastAPI) -> None:
|
|
232
|
+
@app.exception_handler(ApiError)
|
|
233
|
+
async def api_error_handler(request: Request, exc: ApiError):
|
|
234
|
+
return JSONResponse(
|
|
235
|
+
status_code=exc.status_code,
|
|
236
|
+
content={"error": {"code": exc.code, "message": exc.message}},
|
|
237
|
+
)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## OpenAPI Customization
|
|
241
|
+
|
|
242
|
+
Assign the custom OpenAPI callable to `app.openapi`; do not just call the function once.
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
from fastapi import FastAPI
|
|
246
|
+
from fastapi.openapi.utils import get_openapi
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def install_openapi(app: FastAPI) -> None:
|
|
250
|
+
def custom_openapi():
|
|
251
|
+
if app.openapi_schema:
|
|
252
|
+
return app.openapi_schema
|
|
253
|
+
app.openapi_schema = get_openapi(
|
|
254
|
+
title="Service API",
|
|
255
|
+
version="1.0.0",
|
|
256
|
+
routes=app.routes,
|
|
257
|
+
)
|
|
258
|
+
return app.openapi_schema
|
|
259
|
+
|
|
260
|
+
app.openapi = custom_openapi
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Testing
|
|
264
|
+
|
|
265
|
+
Override the dependency used by `Depends`, not an internal helper that route handlers never reference.
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
import pytest
|
|
269
|
+
from httpx import ASGITransport, AsyncClient
|
|
270
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
271
|
+
|
|
272
|
+
from app.dependencies import get_db
|
|
273
|
+
from app.main import create_app
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@pytest.fixture
|
|
277
|
+
async def client(test_session: AsyncSession):
|
|
278
|
+
app = create_app()
|
|
279
|
+
|
|
280
|
+
async def override_get_db():
|
|
281
|
+
yield test_session
|
|
282
|
+
|
|
283
|
+
app.dependency_overrides[get_db] = override_get_db
|
|
284
|
+
async with AsyncClient(
|
|
285
|
+
transport=ASGITransport(app=app),
|
|
286
|
+
base_url="http://test",
|
|
287
|
+
) as test_client:
|
|
288
|
+
yield test_client
|
|
289
|
+
app.dependency_overrides.clear()
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Security Checklist
|
|
293
|
+
|
|
294
|
+
- Hash passwords with `argon2-cffi`, `bcrypt`, or a current passlib-compatible hasher.
|
|
295
|
+
- Validate JWT issuer, audience, expiry, and signing algorithm.
|
|
296
|
+
- Keep CORS origins environment-specific.
|
|
297
|
+
- Put rate limits on auth and write-heavy endpoints.
|
|
298
|
+
- Use Pydantic models for all request bodies.
|
|
299
|
+
- Use ORM parameter binding or SQLAlchemy Core expressions; never build SQL with f-strings.
|
|
300
|
+
- Redact tokens, authorization headers, cookies, and passwords from logs.
|
|
301
|
+
- Run dependency audit tooling in CI.
|
|
302
|
+
|
|
303
|
+
## Performance Checklist
|
|
304
|
+
|
|
305
|
+
- Configure database connection pooling explicitly.
|
|
306
|
+
- Add pagination to list endpoints.
|
|
307
|
+
- Watch for N+1 queries and use eager loading intentionally.
|
|
308
|
+
- Use async HTTP/database clients in async paths.
|
|
309
|
+
- Add compression only after checking payload size and CPU tradeoffs.
|
|
310
|
+
- Cache stable expensive reads behind explicit invalidation.
|
|
311
|
+
|
|
312
|
+
## Examples
|
|
313
|
+
|
|
314
|
+
Use these examples as patterns, not as project-wide templates:
|
|
315
|
+
|
|
316
|
+
- Application factory: configure middleware and routers once in `create_app`.
|
|
317
|
+
- Schema split: `UserCreate`, `UserUpdate`, and `UserResponse` have different responsibilities.
|
|
318
|
+
- Dependency override: tests override `get_db` directly.
|
|
319
|
+
- OpenAPI customization: assign `app.openapi = custom_openapi`.
|
|
320
|
+
|
|
321
|
+
## See Also
|
|
322
|
+
|
|
323
|
+
- Agent: `fastapi-reviewer`
|
|
324
|
+
- Command: `/fastapi-review`
|
|
325
|
+
- Skill: `python-patterns`
|
|
326
|
+
- Skill: `python-testing`
|
|
327
|
+
- Skill: `api-design`
|