start-vibing-stacks 2.6.0 → 2.7.3
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/README.md +83 -135
- package/dist/index.js +16 -2
- package/dist/migrate.d.ts +27 -0
- package/dist/migrate.js +217 -0
- package/dist/setup.js +10 -0
- package/package.json +1 -1
- package/stacks/_shared/agents/claude-md-compactor.md +1 -0
- package/stacks/_shared/agents/commit-manager.md +1 -0
- package/stacks/_shared/agents/documenter.md +1 -0
- package/stacks/_shared/agents/domain-updater.md +1 -0
- package/stacks/_shared/agents/research-web.md +1 -0
- package/stacks/_shared/agents/security-auditor.md +168 -0
- package/stacks/_shared/agents/tester.md +1 -0
- package/stacks/_shared/hooks/final-check.ts +205 -0
- package/stacks/_shared/hooks/stop-validator.ts +77 -1
- package/stacks/_shared/skills/accessibility-wcag22/SKILL.md +284 -0
- package/stacks/_shared/skills/ci-pipelines/SKILL.md +166 -0
- package/stacks/_shared/skills/codebase-knowledge/SKILL.md +5 -0
- package/stacks/_shared/skills/database-migrations/SKILL.md +256 -0
- package/stacks/_shared/skills/debugging-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/docker-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/docs-tracker/SKILL.md +5 -0
- package/stacks/_shared/skills/error-handling/SKILL.md +335 -0
- package/stacks/_shared/skills/final-check/SKILL.md +74 -37
- package/stacks/_shared/skills/git-workflow/SKILL.md +5 -0
- package/stacks/_shared/skills/hook-development/SKILL.md +5 -0
- package/stacks/_shared/skills/observability/SKILL.md +351 -0
- package/stacks/_shared/skills/performance-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/playwright-automation/SKILL.md +5 -0
- package/stacks/_shared/skills/quality-gate/SKILL.md +5 -0
- package/stacks/_shared/skills/research-cache/SKILL.md +5 -0
- package/stacks/_shared/skills/secrets-management/SKILL.md +245 -0
- package/stacks/_shared/skills/security-baseline/SKILL.md +202 -0
- package/stacks/_shared/skills/test-coverage/SKILL.md +5 -0
- package/stacks/_shared/skills/ui-ux-audit/SKILL.md +5 -0
- package/stacks/frontend/react/skills/preline-ui/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-standards/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-ui-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/shadcn-ui/SKILL.md +5 -0
- package/stacks/frontend/react/skills/tailwind-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/zod-validation/SKILL.md +5 -0
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +5 -0
- package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +5 -0
- package/stacks/nodejs/skills/api-security-node/SKILL.md +275 -0
- package/stacks/nodejs/skills/bun-runtime/SKILL.md +5 -0
- package/stacks/nodejs/skills/mongoose-patterns/SKILL.md +5 -0
- package/stacks/nodejs/skills/nextjs-app-router/SKILL.md +5 -0
- package/stacks/nodejs/skills/trpc-api/SKILL.md +5 -0
- package/stacks/nodejs/skills/typescript-strict/SKILL.md +5 -0
- package/stacks/nodejs/stack.json +2 -1
- package/stacks/nodejs/workflows/ci.yml +90 -0
- package/stacks/nodejs/workflows/security.yml +45 -0
- package/stacks/php/skills/api-design/SKILL.md +5 -0
- package/stacks/php/skills/api-security/SKILL.md +5 -0
- package/stacks/php/skills/composer-workflow/SKILL.md +5 -0
- package/stacks/php/skills/external-api-patterns/SKILL.md +5 -0
- package/stacks/php/skills/inertia-react/SKILL.md +5 -0
- package/stacks/php/skills/laravel-inertia-i18n/SKILL.md +5 -0
- package/stacks/php/skills/laravel-octane/SKILL.md +5 -0
- package/stacks/php/skills/laravel-patterns/SKILL.md +5 -0
- package/stacks/php/skills/mariadb-octane/SKILL.md +5 -0
- package/stacks/php/skills/php-patterns/SKILL.md +5 -0
- package/stacks/php/skills/phpstan-analysis/SKILL.md +5 -0
- package/stacks/php/skills/phpunit-testing/SKILL.md +5 -0
- package/stacks/php/skills/security-scan-php/SKILL.md +5 -0
- package/stacks/php/workflows/ci.yml +106 -0
- package/stacks/php/workflows/security.yml +36 -0
- package/stacks/python/skills/api-security-python/SKILL.md +312 -0
- package/stacks/python/skills/async-patterns/SKILL.md +5 -0
- package/stacks/python/skills/django-patterns/SKILL.md +5 -0
- package/stacks/python/skills/fastapi-patterns/SKILL.md +5 -0
- package/stacks/python/skills/pydantic-validation/SKILL.md +5 -0
- package/stacks/python/skills/pytest-testing/SKILL.md +5 -0
- package/stacks/python/skills/python-patterns/SKILL.md +5 -0
- package/stacks/python/skills/python-performance/SKILL.md +5 -0
- package/stacks/python/skills/scripting-automation/SKILL.md +5 -0
- package/stacks/python/stack.json +2 -1
- package/stacks/python/workflows/ci.yml +76 -0
- package/stacks/python/workflows/security.yml +56 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: database-migrations
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: Safe database migrations under load — backfills, concurrent indexes, lock timeouts, parallel-change pattern, rollback strategy. Invoke before writing or running any schema migration.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Database Migrations — Safety Patterns
|
|
8
|
+
|
|
9
|
+
**ALWAYS invoke before writing or executing a schema migration on any environment with data.**
|
|
10
|
+
|
|
11
|
+
> A migration that locks the table for 90 seconds at 1k writes/sec creates a 90,000-request outage. The only safe migration is one that ships in pieces.
|
|
12
|
+
|
|
13
|
+
## The Cardinal Rule: Parallel Change
|
|
14
|
+
|
|
15
|
+
For any non-trivial schema change, deploy in **at least two releases**:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Release N → expand: add new column/table, dual-write, leave old
|
|
19
|
+
Release N+1 → migrate: backfill data
|
|
20
|
+
Release N+2 → contract: drop old, switch reads
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Never combine expand + contract in one deploy. You will regret it the moment you need to roll back.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 1. Adding a Column
|
|
28
|
+
|
|
29
|
+
### NOT NULL with default
|
|
30
|
+
|
|
31
|
+
PostgreSQL ≥ 11 / MySQL 8 / MariaDB 10.5+: `ADD COLUMN ... NOT NULL DEFAULT ...` is fast (metadata-only). On older versions or other engines, it rewrites the whole table.
|
|
32
|
+
|
|
33
|
+
**Safe pattern (works on any version):**
|
|
34
|
+
```sql
|
|
35
|
+
-- Step 1 (release N): nullable, no default → instant
|
|
36
|
+
ALTER TABLE users ADD COLUMN plan VARCHAR(20);
|
|
37
|
+
|
|
38
|
+
-- Step 2 (release N): app dual-writes — sets plan on every INSERT/UPDATE
|
|
39
|
+
-- Step 3 (release N+1): backfill in chunks (see §3)
|
|
40
|
+
-- Step 4 (release N+2): set NOT NULL + default
|
|
41
|
+
ALTER TABLE users ALTER COLUMN plan SET NOT NULL;
|
|
42
|
+
ALTER TABLE users ALTER COLUMN plan SET DEFAULT 'free';
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Generated columns
|
|
46
|
+
Prefer over backfilling derived data when possible — DB keeps it consistent.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## 2. Adding an Index
|
|
51
|
+
|
|
52
|
+
Indexes lock the table during build on most engines.
|
|
53
|
+
|
|
54
|
+
### PostgreSQL
|
|
55
|
+
```sql
|
|
56
|
+
-- WRONG — exclusive lock for the duration
|
|
57
|
+
CREATE INDEX idx_users_email ON users(email);
|
|
58
|
+
|
|
59
|
+
-- CORRECT — non-blocking
|
|
60
|
+
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
|
|
61
|
+
-- Caveats: cannot run inside a transaction; on failure leaves invalid index — drop with `DROP INDEX CONCURRENTLY`
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### MySQL / MariaDB
|
|
65
|
+
InnoDB ≥ 5.6 supports `ALGORITHM=INPLACE, LOCK=NONE` for most index types:
|
|
66
|
+
```sql
|
|
67
|
+
ALTER TABLE users ADD INDEX idx_email (email), ALGORITHM=INPLACE, LOCK=NONE;
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
If `LOCK=NONE` fails, the engine tells you why (e.g. fulltext, spatial). Don't override — the lock is real.
|
|
71
|
+
|
|
72
|
+
### MongoDB
|
|
73
|
+
```js
|
|
74
|
+
db.users.createIndex({ email: 1 }, { background: true }) // 4.2 default; deprecated in 6+ where it's always non-blocking
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Tooling
|
|
78
|
+
- **Postgres**: `pg_repack` for table-rewrite migrations without long locks
|
|
79
|
+
- **MySQL**: `pt-online-schema-change` (Percona) or `gh-ost` (GitHub) — both work via shadow tables + triggers
|
|
80
|
+
- **Mongo**: rolling builds across replica set members
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 3. Backfilling — Chunked
|
|
85
|
+
|
|
86
|
+
Single `UPDATE` over millions of rows = long transaction = long lock = replication lag = page.
|
|
87
|
+
|
|
88
|
+
### SQL (chunked, app-driven)
|
|
89
|
+
```sql
|
|
90
|
+
-- pseudo: loop in app code
|
|
91
|
+
UPDATE users
|
|
92
|
+
SET plan = COALESCE(plan, 'free')
|
|
93
|
+
WHERE id BETWEEN :start AND :end
|
|
94
|
+
AND plan IS NULL;
|
|
95
|
+
-- Sleep 50ms between batches; chunk size 1k–10k rows
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Laravel
|
|
99
|
+
```php
|
|
100
|
+
User::whereNull('plan')->chunkById(1000, function ($users) {
|
|
101
|
+
foreach ($users as $user) {
|
|
102
|
+
$user->update(['plan' => 'free']);
|
|
103
|
+
}
|
|
104
|
+
usleep(50_000); // 50ms breathing room
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Mongoose
|
|
109
|
+
```ts
|
|
110
|
+
const cursor = User.find({ plan: { $exists: false } }).cursor();
|
|
111
|
+
for await (const user of cursor) {
|
|
112
|
+
await User.updateOne({ _id: user._id }, { $set: { plan: 'free' } });
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Django
|
|
117
|
+
Use `RunPython` migration with `atomic=False` and chunk:
|
|
118
|
+
```python
|
|
119
|
+
class Migration(migrations.Migration):
|
|
120
|
+
atomic = False
|
|
121
|
+
operations = [migrations.RunPython(backfill, reverse_code=migrations.RunPython.noop)]
|
|
122
|
+
|
|
123
|
+
def backfill(apps, schema_editor):
|
|
124
|
+
User = apps.get_model("app", "User")
|
|
125
|
+
qs = User.objects.filter(plan__isnull=True).only("id")
|
|
126
|
+
for batch in chunked(qs.iterator(), 1000):
|
|
127
|
+
User.objects.filter(id__in=[u.id for u in batch]).update(plan="free")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 4. Lock Timeouts (Critical)
|
|
133
|
+
|
|
134
|
+
Set a short timeout so a stuck migration fails fast instead of blocking the app:
|
|
135
|
+
|
|
136
|
+
```sql
|
|
137
|
+
-- Postgres
|
|
138
|
+
SET LOCAL lock_timeout = '5s';
|
|
139
|
+
SET LOCAL statement_timeout = '15s';
|
|
140
|
+
ALTER TABLE users ADD COLUMN x INT;
|
|
141
|
+
|
|
142
|
+
-- MySQL / MariaDB
|
|
143
|
+
SET SESSION lock_wait_timeout = 5;
|
|
144
|
+
SET SESSION innodb_lock_wait_timeout = 5;
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Without this, an `ALTER` waiting for a long-running `SELECT` will block every subsequent write behind it (Postgres queue).
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 5. Renaming — Never Direct in Prod
|
|
152
|
+
|
|
153
|
+
Never `ALTER TABLE users RENAME COLUMN email TO email_address` in a single deploy. The old code on the previous release will break.
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
Release N: add new column, dual-write
|
|
157
|
+
Release N+1: backfill, switch reads to new column
|
|
158
|
+
Release N+2: drop old column
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Same for table renames, type changes (often disguised renames + casts), enum value removal.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## 6. Foreign Keys
|
|
166
|
+
|
|
167
|
+
Adding a FK validates every existing row. On large tables this locks.
|
|
168
|
+
|
|
169
|
+
### Postgres
|
|
170
|
+
```sql
|
|
171
|
+
-- Two-step
|
|
172
|
+
ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; -- fast
|
|
173
|
+
-- Validate when load is low; takes only SHARE UPDATE EXCLUSIVE lock
|
|
174
|
+
ALTER TABLE orders VALIDATE CONSTRAINT fk_user;
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### MySQL — no equivalent; use offline tooling (`gh-ost`).
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## 7. Dropping — Always Soft First
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
Release N: stop writing to column (deploy)
|
|
185
|
+
Release N+1: stop reading from column (deploy) ← observe metrics
|
|
186
|
+
Release N+2: drop column
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Add a feature flag for the new code path so you can roll back without a schema change.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 8. Migration Tooling Per Stack
|
|
194
|
+
|
|
195
|
+
| Stack | Tool | Notes |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| Node + SQL | Drizzle / Kysely / Knex / TypeORM | Drizzle is the modern choice; uses `meta` snapshots |
|
|
198
|
+
| Node + Mongo | Mongoose schema versioning | No DDL — but evolve with backfills |
|
|
199
|
+
| Python + SQL | Alembic (SQLAlchemy), Django migrations | |
|
|
200
|
+
| PHP | Laravel migrations + `doctrine/dbal` for DDL | |
|
|
201
|
+
|
|
202
|
+
Rules regardless of tool:
|
|
203
|
+
- Migration files are **append-only** once merged. Edit = rewrite history = production drift.
|
|
204
|
+
- Each migration has both `up` and `down` (or a documented "no rollback" reason).
|
|
205
|
+
- Migrations run idempotently (safe to re-run).
|
|
206
|
+
- Never include data migrations in schema migrations on huge tables — separate scripts.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## 9. Rollback Strategy
|
|
211
|
+
|
|
212
|
+
For every migration ask: **"How do I undo this in 5 minutes if prod breaks?"**
|
|
213
|
+
|
|
214
|
+
| Operation | Rollback |
|
|
215
|
+
|---|---|
|
|
216
|
+
| Add nullable column | `DROP COLUMN` (instant) |
|
|
217
|
+
| Add NOT NULL column with default | `DROP COLUMN` |
|
|
218
|
+
| Add index | `DROP INDEX [CONCURRENTLY]` |
|
|
219
|
+
| Rename column | None — that's why parallel change |
|
|
220
|
+
| Drop column | Restore from backup. **Don't.** |
|
|
221
|
+
| Backfill | Run inverse update OR accept the data is now correct |
|
|
222
|
+
|
|
223
|
+
If you can't write a 5-minute rollback, the migration is too risky for one release. Split it.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 10. Pre-Deploy Checklist
|
|
228
|
+
|
|
229
|
+
- [ ] Migration runs in < 5s OR is concurrent / non-blocking
|
|
230
|
+
- [ ] Lock + statement timeouts set
|
|
231
|
+
- [ ] Tested on a copy of prod-sized data (or a representative subset)
|
|
232
|
+
- [ ] Backfills are chunked with breathing room
|
|
233
|
+
- [ ] No combined expand + contract in same deploy
|
|
234
|
+
- [ ] App code on previous release survives if migration runs first
|
|
235
|
+
- [ ] App code on next release survives if migration is delayed
|
|
236
|
+
- [ ] Rollback plan written (or "no rollback" justified)
|
|
237
|
+
- [ ] DBA / on-call notified for high-risk migrations
|
|
238
|
+
|
|
239
|
+
## FORBIDDEN
|
|
240
|
+
|
|
241
|
+
| Pattern | Reason |
|
|
242
|
+
|---|---|
|
|
243
|
+
| `ALTER TABLE` without lock timeout in prod | Single bad query = full outage |
|
|
244
|
+
| `CREATE INDEX` without `CONCURRENTLY` (Postgres) | Locks writes |
|
|
245
|
+
| Renaming a column in one deploy | Breaks rolling deploys |
|
|
246
|
+
| Single huge `UPDATE` | Replication lag, locks, redo bloat |
|
|
247
|
+
| Editing a merged migration file | Prod drift, dev/prod mismatch |
|
|
248
|
+
| Dropping a column without two prior soft-delete deploys | No rollback |
|
|
249
|
+
| Migrations running automatically on every deploy without gates | Surprise outage |
|
|
250
|
+
| Mixing schema and data migrations on hot tables | Single-tx behavior unpredictable |
|
|
251
|
+
|
|
252
|
+
## See Also
|
|
253
|
+
|
|
254
|
+
- `observability` — track migration duration and lag during runs
|
|
255
|
+
- `error-handling` — service-side feature flags for parallel change
|
|
256
|
+
- `ci-pipelines` — gate migrations behind manual approval for production
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: error-handling
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: Universal error-handling patterns. Error taxonomy, Result types, error boundaries, structured exceptions, never-swallow rules. Invoke when designing service layers, API responses, or any try/catch.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Error Handling
|
|
8
|
+
|
|
9
|
+
**ALWAYS invoke when adding try/catch, designing service layers, API error responses, or handling external API failures.**
|
|
10
|
+
|
|
11
|
+
> The default state of software is broken. Errors are not exceptional — handling them is the job.
|
|
12
|
+
|
|
13
|
+
## Core Principles
|
|
14
|
+
|
|
15
|
+
1. **Errors are values.** Plan for them in the type signature, not as a side channel.
|
|
16
|
+
2. **Throw at boundaries, return at edges.** Internal modules return Results; HTTP layer translates to status codes; user sees a generic message.
|
|
17
|
+
3. **Never swallow.** A `catch` with no log, no rethrow, and no recovery is a bug.
|
|
18
|
+
4. **Fail closed.** On unexpected error, deny — don't continue with degraded state.
|
|
19
|
+
5. **Distinguish operational from programmer errors.** Operational = expected (network blip, validation). Programmer = bug. They get different treatment.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Error Taxonomy (mandatory)
|
|
24
|
+
|
|
25
|
+
Every error you create classifies into one of these:
|
|
26
|
+
|
|
27
|
+
| Class | Examples | Strategy |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| **Validation** | Bad input, schema mismatch | Return 4xx with field details; do not log as error |
|
|
30
|
+
| **Authentication** | Missing/expired/invalid token | 401, log at info |
|
|
31
|
+
| **Authorization** | Authenticated but not allowed | 403, log at warn (could be probing) |
|
|
32
|
+
| **NotFound** | Resource missing | 404, log at info |
|
|
33
|
+
| **Conflict** | Optimistic lock, duplicate key | 409, log at warn |
|
|
34
|
+
| **RateLimit** | Quota exceeded | 429 + `Retry-After`, log at info |
|
|
35
|
+
| **External** | Upstream API failed | Retry+backoff if idempotent; 502/503; log at error |
|
|
36
|
+
| **Programmer** | Null deref, type mismatch, switch missed | 500, log at error, alert, fix |
|
|
37
|
+
|
|
38
|
+
If the error doesn't fit, you have a new class — add it deliberately.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Pattern 1 — Custom Error Classes (TS / Python / PHP)
|
|
43
|
+
|
|
44
|
+
### TypeScript
|
|
45
|
+
```ts
|
|
46
|
+
// lib/errors.ts
|
|
47
|
+
export class AppError extends Error {
|
|
48
|
+
constructor(
|
|
49
|
+
public readonly code: string,
|
|
50
|
+
public readonly httpStatus: number,
|
|
51
|
+
message: string,
|
|
52
|
+
public readonly cause?: unknown,
|
|
53
|
+
public readonly meta?: Record<string, unknown>,
|
|
54
|
+
) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = this.constructor.name;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class ValidationError extends AppError {
|
|
61
|
+
constructor(message: string, fields?: Record<string, string[]>) {
|
|
62
|
+
super('validation_error', 422, message, undefined, { fields });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export class NotFoundError extends AppError {
|
|
66
|
+
constructor(resource: string) { super('not_found', 404, `${resource} not found`); }
|
|
67
|
+
}
|
|
68
|
+
export class UnauthorizedError extends AppError {
|
|
69
|
+
constructor(message = 'Unauthorized') { super('unauthorized', 401, message); }
|
|
70
|
+
}
|
|
71
|
+
export class ForbiddenError extends AppError {
|
|
72
|
+
constructor(message = 'Forbidden') { super('forbidden', 403, message); }
|
|
73
|
+
}
|
|
74
|
+
export class ConflictError extends AppError {
|
|
75
|
+
constructor(message: string) { super('conflict', 409, message); }
|
|
76
|
+
}
|
|
77
|
+
export class ExternalError extends AppError {
|
|
78
|
+
constructor(service: string, cause: unknown) {
|
|
79
|
+
super('external_error', 502, `Upstream ${service} failed`, cause);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Python
|
|
85
|
+
```python
|
|
86
|
+
class AppError(Exception):
|
|
87
|
+
code: str = "error"
|
|
88
|
+
http_status: int = 500
|
|
89
|
+
def __init__(self, message: str, *, cause: Exception | None = None, meta: dict | None = None):
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
self.cause, self.meta = cause, meta or {}
|
|
92
|
+
|
|
93
|
+
class ValidationError(AppError): code, http_status = "validation_error", 422
|
|
94
|
+
class NotFoundError(AppError): code, http_status = "not_found", 404
|
|
95
|
+
class UnauthorizedError(AppError):code, http_status = "unauthorized", 401
|
|
96
|
+
class ForbiddenError(AppError): code, http_status = "forbidden", 403
|
|
97
|
+
class ConflictError(AppError): code, http_status = "conflict", 409
|
|
98
|
+
class ExternalError(AppError): code, http_status = "external_error", 502
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### PHP
|
|
102
|
+
Use Laravel's `HttpException` family + custom domain exceptions. Override `Handler::register` to map domain exceptions to HTTP responses.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Pattern 2 — Result Types (for internal layers)
|
|
107
|
+
|
|
108
|
+
Throwing across many layers is expensive (TypeScript) and opaque (it's not in the signature). For internal service code, use a Result.
|
|
109
|
+
|
|
110
|
+
### TypeScript
|
|
111
|
+
```ts
|
|
112
|
+
type Result<T, E = AppError> =
|
|
113
|
+
| { ok: true; data: T }
|
|
114
|
+
| { ok: false; error: E };
|
|
115
|
+
|
|
116
|
+
const ok = <T>(data: T): Result<T> => ({ ok: true, data });
|
|
117
|
+
const err = <E extends AppError>(error: E): Result<never, E> => ({ ok: false, error });
|
|
118
|
+
|
|
119
|
+
// Service returns Result; caller handles both branches
|
|
120
|
+
async function findUser(id: string): Promise<Result<User>> {
|
|
121
|
+
const user = await db.user.findById(id);
|
|
122
|
+
if (!user) return err(new NotFoundError('user'));
|
|
123
|
+
return ok(user);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Caller (route handler) — throws to be caught by error middleware
|
|
127
|
+
const result = await findUser(id);
|
|
128
|
+
if (!result.ok) throw result.error;
|
|
129
|
+
return result.data;
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Python
|
|
133
|
+
```python
|
|
134
|
+
from dataclasses import dataclass
|
|
135
|
+
from typing import Generic, TypeVar
|
|
136
|
+
T = TypeVar("T"); E = TypeVar("E")
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class Ok(Generic[T]): data: T
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class Err(Generic[E]): error: E
|
|
142
|
+
|
|
143
|
+
Result = Ok[T] | Err[E]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Or use `returns` library: `from returns.result import Result, Success, Failure`.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Pattern 3 — Centralized HTTP Error Handler
|
|
151
|
+
|
|
152
|
+
### Express
|
|
153
|
+
```ts
|
|
154
|
+
// errors of type AppError → mapped; everything else → 500
|
|
155
|
+
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
|
|
156
|
+
const requestId = res.getHeader('x-request-id');
|
|
157
|
+
if (err instanceof AppError) {
|
|
158
|
+
req.log.warn({ err, code: err.code, status: err.httpStatus }, err.message);
|
|
159
|
+
return res.status(err.httpStatus).json({
|
|
160
|
+
error: { code: err.code, message: err.message, requestId, ...err.meta },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// Unknown — programmer error, never leak details
|
|
164
|
+
req.log.error({ err }, 'Unhandled exception');
|
|
165
|
+
res.status(500).json({ error: { code: 'internal_error', message: 'Internal Server Error', requestId } });
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Next.js — App Router
|
|
170
|
+
Use `error.tsx` + `global-error.tsx` for client-rendered errors, and a wrapper for Route Handlers:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// app/api/_handler.ts
|
|
174
|
+
export function safeHandler<T extends Request>(fn: (req: T) => Promise<Response>) {
|
|
175
|
+
return async (req: T): Promise<Response> => {
|
|
176
|
+
try { return await fn(req); }
|
|
177
|
+
catch (e) {
|
|
178
|
+
if (e instanceof AppError) {
|
|
179
|
+
return Response.json({ error: { code: e.code, message: e.message, ...e.meta } }, { status: e.httpStatus });
|
|
180
|
+
}
|
|
181
|
+
logger.error({ err: e }, 'Unhandled');
|
|
182
|
+
return Response.json({ error: { code: 'internal_error', message: 'Internal Server Error' } }, { status: 500 });
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### FastAPI
|
|
189
|
+
```python
|
|
190
|
+
from fastapi import FastAPI, Request
|
|
191
|
+
from fastapi.responses import JSONResponse
|
|
192
|
+
|
|
193
|
+
@app.exception_handler(AppError)
|
|
194
|
+
async def app_error_handler(req: Request, exc: AppError):
|
|
195
|
+
return JSONResponse(
|
|
196
|
+
{"error": {"code": exc.code, "message": str(exc), **exc.meta}},
|
|
197
|
+
status_code=exc.http_status,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
@app.exception_handler(Exception)
|
|
201
|
+
async def unhandled_handler(req: Request, exc: Exception):
|
|
202
|
+
log.exception("unhandled", request_id=req.headers.get("x-request-id"))
|
|
203
|
+
return JSONResponse(
|
|
204
|
+
{"error": {"code": "internal_error", "message": "Internal Server Error"}},
|
|
205
|
+
status_code=500,
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Pattern 4 — Retry with Backoff (External Calls)
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// Idempotent operations only. Never retry payments without idempotency keys.
|
|
215
|
+
async function withRetry<T>(
|
|
216
|
+
fn: () => Promise<T>,
|
|
217
|
+
{ tries = 3, baseMs = 200, factor = 2, jitter = 0.3 } = {},
|
|
218
|
+
): Promise<T> {
|
|
219
|
+
let lastErr: unknown;
|
|
220
|
+
for (let i = 0; i < tries; i++) {
|
|
221
|
+
try { return await fn(); }
|
|
222
|
+
catch (e) {
|
|
223
|
+
lastErr = e;
|
|
224
|
+
if (i === tries - 1) break;
|
|
225
|
+
const delay = baseMs * Math.pow(factor, i) * (1 + (Math.random() - 0.5) * 2 * jitter);
|
|
226
|
+
await new Promise(r => setTimeout(r, delay));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
throw new ExternalError('upstream', lastErr);
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
For HTTP retries, use `axios-retry`, `got` (built-in), or `tenacity` (Python). Don't roll your own unless you must.
|
|
234
|
+
|
|
235
|
+
**Idempotency keys** for non-idempotent operations (POST that creates / charges):
|
|
236
|
+
```ts
|
|
237
|
+
await stripe.charges.create({ amount, customer }, { idempotencyKey: orderId });
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Pattern 5 — Circuit Breaker (External Outages)
|
|
243
|
+
|
|
244
|
+
When upstream is down, fail fast instead of piling up requests:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
import CircuitBreaker from 'opossum';
|
|
248
|
+
const breaker = new CircuitBreaker(callUpstream, {
|
|
249
|
+
timeout: 3000,
|
|
250
|
+
errorThresholdPercentage: 50,
|
|
251
|
+
resetTimeout: 30000,
|
|
252
|
+
});
|
|
253
|
+
breaker.fallback(() => ({ data: cached, stale: true }));
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Python: `pybreaker`. PHP: `ackintosh/ganesha`.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Pattern 6 — Async / Promise Hygiene
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
// WRONG — unhandled rejection crashes the process (Node 15+)
|
|
264
|
+
somePromise();
|
|
265
|
+
|
|
266
|
+
// CORRECT — always handle, even if just to log
|
|
267
|
+
somePromise().catch(err => logger.error({ err }, 'background task failed'));
|
|
268
|
+
|
|
269
|
+
// Promise.all rejects on first failure — for partial success use allSettled
|
|
270
|
+
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
|
|
271
|
+
const failures = results.filter(r => r.status === 'rejected');
|
|
272
|
+
if (failures.length > 0) logger.warn({ failures }, 'partial failure');
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Catch handler at process level (defense in depth):
|
|
276
|
+
```ts
|
|
277
|
+
process.on('unhandledRejection', (reason) => {
|
|
278
|
+
logger.fatal({ reason }, 'Unhandled rejection');
|
|
279
|
+
process.exit(1);
|
|
280
|
+
});
|
|
281
|
+
process.on('uncaughtException', (err) => {
|
|
282
|
+
logger.fatal({ err }, 'Uncaught exception');
|
|
283
|
+
process.exit(1);
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Anti-Patterns
|
|
290
|
+
|
|
291
|
+
| Anti-pattern | Why it's bad | Fix |
|
|
292
|
+
|---|---|---|
|
|
293
|
+
| `try { ... } catch {}` | Silent failure | Log + rethrow or recover deliberately |
|
|
294
|
+
| `catch (e) { console.log(e) }` | No structured context | Use logger; include `request_id`, `user_id` |
|
|
295
|
+
| `catch (e) { throw new Error(e.message) }` | Loses stack trace | `throw new AppError(..., e)` (preserve cause) |
|
|
296
|
+
| `if (err) return null` | Caller can't distinguish "no result" from "failed" | Result type |
|
|
297
|
+
| Returning `{ error: 'something' }` from a function | Convention drift, untyped | Result type or throw |
|
|
298
|
+
| Catching `Error` to coerce | Hides bugs | Catch specific classes |
|
|
299
|
+
| Generic 500 response with raw stack | Info disclosure | Log full, return generic message + requestId |
|
|
300
|
+
| Retrying non-idempotent calls | Double-charge / double-create | Idempotency keys |
|
|
301
|
+
| Retrying without backoff | DoS your own upstream | Exponential + jitter |
|
|
302
|
+
|
|
303
|
+
## API Error Response Shape (Standardize)
|
|
304
|
+
|
|
305
|
+
```json
|
|
306
|
+
{
|
|
307
|
+
"error": {
|
|
308
|
+
"code": "validation_error",
|
|
309
|
+
"message": "Invalid input",
|
|
310
|
+
"fields": {
|
|
311
|
+
"email": ["must be a valid email"],
|
|
312
|
+
"age": ["must be at least 13"]
|
|
313
|
+
},
|
|
314
|
+
"requestId": "req_abc123"
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Stable contract: client code can switch on `code`. Always include `requestId` so support can correlate with logs.
|
|
320
|
+
|
|
321
|
+
## Pre-Commit Checklist
|
|
322
|
+
|
|
323
|
+
- [ ] No empty `catch` blocks
|
|
324
|
+
- [ ] Every `catch` either logs+rethrows, recovers deliberately, or transforms to `AppError`
|
|
325
|
+
- [ ] Service-layer functions that can fail return `Result` or throw `AppError`
|
|
326
|
+
- [ ] HTTP layer has a single error mapper; routes do not stringify exceptions
|
|
327
|
+
- [ ] External calls have retry + backoff (if idempotent) or idempotency keys
|
|
328
|
+
- [ ] No raw stack traces in API responses
|
|
329
|
+
- [ ] Process-level `unhandledRejection` / `uncaughtException` handlers exist (Node)
|
|
330
|
+
|
|
331
|
+
## See Also
|
|
332
|
+
|
|
333
|
+
- `observability` — what to log on errors
|
|
334
|
+
- `security-baseline` — A09 logging, A05 config (don't leak stack traces)
|
|
335
|
+
- Stack `api-security-*` — error mapping in framework handlers
|