vibecodeagent 0.0.0-stage → 0.1.64
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 +37 -2
- package/THIRD-PARTY-NOTICES.txt +1427 -0
- package/dist/agen.js +138895 -0
- package/dist/bin.js +1245 -0
- package/dist/daemon.js +163664 -0
- package/dist/node_modules/node-ensure/index.js +10 -0
- package/dist/node_modules/node-ensure/package.json +31 -0
- package/dist/node_modules/pdf-parse/lib/pdf-parse.js +102 -0
- package/dist/node_modules/pdf-parse/lib/pdf.js/v1.10.100/build/pdf.js +18538 -0
- package/dist/node_modules/pdf-parse/lib/pdf.js/v1.10.100/build/pdf.worker.js +45507 -0
- package/dist/node_modules/pdf-parse/package.json +49 -0
- package/dist/node_modules/sql.js/dist/sql-wasm.js +186 -0
- package/dist/node_modules/sql.js/dist/sql-wasm.wasm +0 -0
- package/dist/node_modules/sql.js/package.json +58 -0
- package/dist/resources/skills/builtin/.gitkeep +0 -0
- package/dist/resources/skills/builtin/NOTICE.md +35 -0
- package/dist/resources/skills/builtin/README.md +74 -0
- package/dist/resources/skills/builtin/backend-engineering/SKILL.md +102 -0
- package/dist/resources/skills/builtin/backend-engineering/references/api-contracts.md +320 -0
- package/dist/resources/skills/builtin/backend-engineering/references/auth-and-secrets.md +268 -0
- package/dist/resources/skills/builtin/backend-engineering/references/concurrency-and-jobs.md +386 -0
- package/dist/resources/skills/builtin/backend-engineering/references/data-modelling.md +367 -0
- package/dist/resources/skills/builtin/backend-engineering/references/deploy-and-config.md +228 -0
- package/dist/resources/skills/builtin/backend-engineering/references/migrations.md +320 -0
- package/dist/resources/skills/builtin/backend-engineering/references/observability.md +301 -0
- package/dist/resources/skills/builtin/backend-engineering/references/plan-template.md +152 -0
- package/dist/resources/skills/builtin/backend-engineering/references/review-audit.md +213 -0
- package/dist/resources/skills/builtin/backend-engineering/references/security.md +251 -0
- package/dist/resources/skills/builtin/backend-engineering/references/stack-go.md +238 -0
- package/dist/resources/skills/builtin/backend-engineering/references/stack-node.md +274 -0
- package/dist/resources/skills/builtin/backend-engineering/references/stack-python.md +296 -0
- package/dist/resources/skills/builtin/backend-engineering/references/testing-and-perf.md +298 -0
- package/dist/resources/skills/builtin/backend-engineering/references/validation-and-errors.md +266 -0
- package/dist/resources/skills/builtin/dasar-vibecode/SKILL.md +312 -0
- package/dist/resources/skills/builtin/excel-maker/SKILL.md +71 -0
- package/dist/resources/skills/builtin/frontend-design/SKILL.md +131 -0
- package/dist/resources/skills/builtin/frontend-design/data/app-interface.csv +33 -0
- package/dist/resources/skills/builtin/frontend-design/data/catalog-summary.json +78 -0
- package/dist/resources/skills/builtin/frontend-design/data/charts.csv +26 -0
- package/dist/resources/skills/builtin/frontend-design/data/colors.csv +193 -0
- package/dist/resources/skills/builtin/frontend-design/data/data-provenance.json +1348 -0
- package/dist/resources/skills/builtin/frontend-design/data/google-font-licenses.json +20185 -0
- package/dist/resources/skills/builtin/frontend-design/data/google-fonts.csv +1935 -0
- package/dist/resources/skills/builtin/frontend-design/data/icons.csv +106 -0
- package/dist/resources/skills/builtin/frontend-design/data/landing.csv +35 -0
- package/dist/resources/skills/builtin/frontend-design/data/motion.csv +18 -0
- package/dist/resources/skills/builtin/frontend-design/data/phosphor-icons-upstream.json +32582 -0
- package/dist/resources/skills/builtin/frontend-design/data/products.csv +193 -0
- package/dist/resources/skills/builtin/frontend-design/data/react-performance.csv +45 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/angular.csv +51 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/astro.csv +54 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/avalonia.csv +57 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/flutter.csv +53 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/html-tailwind.csv +60 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/javafx.csv +76 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/jetpack-compose.csv +53 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/laravel.csv +51 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/nextjs.csv +62 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/nuxt-ui.csv +71 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/nuxtjs.csv +68 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/react-native.csv +52 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/react.csv +62 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/shadcn.csv +69 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/svelte.csv +56 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/swiftui.csv +51 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/threejs.csv +54 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/uno.csv +60 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/uwp.csv +56 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/vue.csv +50 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/winui.csv +60 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/wpf.csv +57 -0
- package/dist/resources/skills/builtin/frontend-design/data/styles.csv +89 -0
- package/dist/resources/skills/builtin/frontend-design/data/typography.csv +75 -0
- package/dist/resources/skills/builtin/frontend-design/data/ui-reasoning.csv +193 -0
- package/dist/resources/skills/builtin/frontend-design/data/ux-guidelines.csv +120 -0
- package/dist/resources/skills/builtin/frontend-design/references/animation-patterns.md +705 -0
- package/dist/resources/skills/builtin/frontend-design/references/app-polish.md +117 -0
- package/dist/resources/skills/builtin/frontend-design/references/apple-fluid.md +278 -0
- package/dist/resources/skills/builtin/frontend-design/references/art-direction.md +280 -0
- package/dist/resources/skills/builtin/frontend-design/references/case-studies.md +599 -0
- package/dist/resources/skills/builtin/frontend-design/references/craft.md +128 -0
- package/dist/resources/skills/builtin/frontend-design/references/expo.md +251 -0
- package/dist/resources/skills/builtin/frontend-design/references/layout-systems.md +533 -0
- package/dist/resources/skills/builtin/frontend-design/references/libraries.md +72 -0
- package/dist/resources/skills/builtin/frontend-design/references/motion.md +222 -0
- package/dist/resources/skills/builtin/frontend-design/references/picker.md +197 -0
- package/dist/resources/skills/builtin/frontend-design/references/plan-template.md +73 -0
- package/dist/resources/skills/builtin/frontend-design/references/prototype.md +85 -0
- package/dist/resources/skills/builtin/frontend-design/references/recipes-expo.md +385 -0
- package/dist/resources/skills/builtin/frontend-design/references/recipes-web.md +421 -0
- package/dist/resources/skills/builtin/frontend-design/references/review-audit.md +107 -0
- package/dist/resources/skills/builtin/frontend-design/references/sonner.md +143 -0
- package/dist/resources/skills/builtin/frontend-design/references/technical-stack.md +543 -0
- package/dist/resources/skills/builtin/frontend-design/references/theming.md +60 -0
- package/dist/resources/skills/builtin/frontend-design/references/typography.md +393 -0
- package/dist/resources/skills/builtin/frontend-design/references/ux-rules.md +256 -0
- package/dist/resources/skills/builtin/frontend-design/references/vocabulary.md +169 -0
- package/dist/resources/skills/builtin/frontend-design/scripts/core.py +993 -0
- package/dist/resources/skills/builtin/frontend-design/scripts/design_system.py +1643 -0
- package/dist/resources/skills/builtin/frontend-design/scripts/reasoning_contract.py +123 -0
- package/dist/resources/skills/builtin/frontend-design/scripts/search.py +171 -0
- package/dist/resources/skills/builtin/frontend-design/scripts/validate_data.py +1091 -0
- package/dist/resources/skills/builtin/kerja-agentik/SKILL.md +117 -0
- package/dist/resources/skills/builtin/pdf-maker/SKILL.md +51 -0
- package/dist/resources/skills/builtin/tinjau-keamanan/SKILL.md +53 -0
- package/dist/resources/skills/builtin/word-maker/SKILL.md +58 -0
- package/dist/resources/skills/builtin/write-swift/SKILL.md +388 -0
- package/package.json +35 -4
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# Testing and Performance
|
|
2
|
+
|
|
3
|
+
This file owns two claims: *this service works* and *this service is fast enough*. That means the test shape for a backend, what may be faked, how test data is built, and the latency/throughput playbook — triage order, pool sizing, caching, load testing. Schema, index design, and the N+1 *fixes* are [data-modelling.md](data-modelling.md); this file owns finding the problem and proving the fix. Locks, transactions, and queue mechanics are [concurrency-and-jobs.md](concurrency-and-jobs.md) — the concurrency *test* is here. What a slow request emits, the histogram it lands in, and the alert on it are [observability.md](observability.md). Timeout values, env config, and graceful shutdown are [deploy-and-config.md](deploy-and-config.md). Assertions on parsed input and error bodies are [validation-and-errors.md](validation-and-errors.md).
|
|
4
|
+
|
|
5
|
+
`kerja-agentik` owns running the suite, reading the failure, and the rule that you never claim a test passes without executing it. Nothing here repeats that. This file only decides **what to assert and what to measure**.
|
|
6
|
+
|
|
7
|
+
## Operating Posture
|
|
8
|
+
|
|
9
|
+
Two failure modes, and the first is worse:
|
|
10
|
+
|
|
11
|
+
1. **A green suite against something that is not your production database.** A mocked repository, a fake in-memory store, SQLite standing in for Postgres. The tests pass forever, including on the commit that ships the bug, because the constraint, the lock, and the type coercion under test do not exist in the thing you tested against. This defect is invisible in review — the diff looks like a well-tested feature.
|
|
12
|
+
2. **Optimising by guess.** Rewriting the serialiser while the N+1 sits untouched, or raising the pool size to fix a stall that a bigger pool makes worse. Costs a week, moves p99 by nothing, and leaves the real cause in place with a fresh commit on top of it.
|
|
13
|
+
|
|
14
|
+
**The right number of new tests is sometimes zero, and the right cache is often no cache.** A test that asserts the framework routes a request, a second test at a different page size that encodes the same property, a cache in front of a query that is missing an index — all defects. Say "this needs an index, not a cache" and add nothing.
|
|
15
|
+
|
|
16
|
+
## 1. The Test Shape for a Service
|
|
17
|
+
|
|
18
|
+
Not the pyramid. A service is thick in the middle, because a backend's logic is mostly SQL, constraints, transaction boundaries, and authorization filters, and none of those are pure functions.
|
|
19
|
+
|
|
20
|
+
| Layer | Share | What lives there | Touches |
|
|
21
|
+
| --- | --- | --- | --- |
|
|
22
|
+
| **Unit** | thin | Genuinely pure logic: money arithmetic, a state machine, a parser, a permission predicate over already-loaded data | Nothing |
|
|
23
|
+
| **Integration** | **thick — the bulk** | A handler end-to-end in-process: real router, real parsing, real queries, real transactions, real constraints, real authorization | Real database, stubbed third-party HTTP |
|
|
24
|
+
| **End-to-end** | a few | The deployed process over the network: boot, config, migrations, health, one happy path per critical flow, one auth failure | Everything real, sandbox providers |
|
|
25
|
+
|
|
26
|
+
**The integration layer is where backend bugs actually are.** The bug is a missing `WHERE tenant_id`, a `UNIQUE` that fires under a race, a `NOT NULL` the code path never sets, a migration that has not run, a rollback that leaves a row. A unit test cannot see any of them, because in a unit test none of them exist.
|
|
27
|
+
|
|
28
|
+
| The change | The test that proves it |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| New endpoint | Integration: happy path, one `4xx` per validation class, one authorization denial from a *different* tenant |
|
|
31
|
+
| A query got a filter | Integration: seed a row that must be excluded, assert it is absent |
|
|
32
|
+
| A migration | Integration suite runs against a database built by the migration chain |
|
|
33
|
+
| Money/time arithmetic | Unit, exhaustively — rounding, negative, zero, DST boundary |
|
|
34
|
+
| A write that can collide | Concurrency test (§5) — not a unit test |
|
|
35
|
+
| A serialiser field rename | Integration: assert the response body keys, not the model |
|
|
36
|
+
|
|
37
|
+
## 2. Test Against a Real Database
|
|
38
|
+
|
|
39
|
+
Not negotiable. The bugs live in the SQL, the constraints, the transaction semantics, and the migration; a mock contains none of those, and neither does another engine.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Ephemeral schema per test — fastest, one Postgres, CREATE SCHEMA t_<uuid>; search_path
|
|
43
|
+
Template database — CREATE DATABASE test_x TEMPLATE tmpl (no session may be
|
|
44
|
+
connected to tmpl), migrations run once per suite
|
|
45
|
+
Testcontainers — one container per suite run; use for CI parity and when the
|
|
46
|
+
suite needs extensions, roles, or a replica
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Build the test database by running the migrations, never by loading a hand-written `schema.sql`.** Otherwise the migration chain is the one thing in the repo with no test, and it breaks in production ([migrations.md](migrations.md)).
|
|
50
|
+
|
|
51
|
+
Between tests, prefer `TRUNCATE <tables> RESTART IDENTITY CASCADE` over wrapping each test in a rolled-back transaction. The rollback trick is faster and breaks the moment the code under test opens its own transaction, commits, tests `SERIALIZABLE` retry, or asserts on an `AFTER COMMIT` hook — the symptom is a test that passes in isolation and reports "no rows" when the handler commits into a transaction that never lands.
|
|
52
|
+
|
|
53
|
+
**Named forbidden substitutes.** These are the plausible-looking guesses; refuse each by name:
|
|
54
|
+
|
|
55
|
+
| Substitute | Why it produces a false green |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| SQLite for Postgres | Type affinity: `'abc'` into an `INTEGER` column stores the string, silently. `PRAGMA foreign_keys` is **OFF** by default, so FK violations do not raise. No `FOR UPDATE`, no `SKIP LOCKED`, no advisory locks — every locking test is a no-op. No `jsonb`, `timestamptz`, arrays, `ILIKE`, `DISTINCT ON`. Database-wide single writer, so no test can observe a row-level race. |
|
|
58
|
+
| `pg-mem` / H2 / an in-memory dialect | Reimplements a subset. The planner, the constraint timing, and the isolation semantics you are testing are the subset it skipped. |
|
|
59
|
+
| A mocked repository / stubbed ORM session | Asserts that your mock returns what you told it to. Tests the test. |
|
|
60
|
+
| Mocking your own service/module | Same. If it is your code, run it. |
|
|
61
|
+
|
|
62
|
+
## 3. What to Fake, and How
|
|
63
|
+
|
|
64
|
+
| Dependency | Fake it? | How |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| Your database | **Never** | §2 |
|
|
67
|
+
| Your own modules | **Never** | Call them |
|
|
68
|
+
| Third-party HTTP | **Yes**, at the HTTP layer | `nock`, `responses`/`respx`, `httpx.MockTransport`, WireMock — not by stubbing the client class |
|
|
69
|
+
| The clock | **Yes**, injected | A `now()` dependency passed in |
|
|
70
|
+
| Randomness, uuid, token generation | **Yes**, injected | A seeded generator passed in |
|
|
71
|
+
| Queue / broker | **Yes** for producers | Assert the enqueued message; test the handler by calling it directly |
|
|
72
|
+
| Payments, email, SMS | Stub in integration; sandbox in one e2e | Provider sandbox keys, never live |
|
|
73
|
+
|
|
74
|
+
**Stub at the transport, not at the client object.** A stubbed `PaymentClient.charge()` can only return what you imagined. An HTTP-level stub can return `503`, a truncated body, a `200` with the wrong content type, and a socket that never answers — which is how the dependency actually fails, and the only way to test the timeout and retry path ([concurrency-and-jobs.md](concurrency-and-jobs.md)).
|
|
75
|
+
|
|
76
|
+
**Inject time and randomness; do not patch them globally.** `freezegun`, `jest.useFakeTimers()`, and monkeypatched `time.time` freeze the whole process — including the connection pool's keepalive, the driver's statement timeout, and the test framework's own timers. The symptom is unmistakable and gets misdiagnosed for days: a test that passes alone and **hangs** in the full suite, or a pool that never reaps because its timer is pinned to 2021.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# Injected, not patched. The handler takes its clock; the test hands it one.
|
|
80
|
+
def expire_session(session, *, now: Callable[[], datetime]) -> bool:
|
|
81
|
+
return session.expires_at <= now()
|
|
82
|
+
|
|
83
|
+
expire_session(s, now=lambda: FIXED) # deterministic, process-wide side effects: none
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 4. Test Data: Factories, Never a Shared Seed
|
|
87
|
+
|
|
88
|
+
A factory builds exactly the row the test asserts on and fills every other required column with a unique generated default. A shared `seed.sql` or a session-scoped fixture set couples every test to every other test.
|
|
89
|
+
|
|
90
|
+
The symptom: **the failing test is one nobody touched.** Someone adds a row to the seed file for their feature, and a count assertion, an ordering assertion, or a "first result" assertion in an unrelated module goes red. The team's next move is to make the assertions looser, and from then on the suite proves less every month.
|
|
91
|
+
|
|
92
|
+
- Every value the test asserts on appears **in the test body**. If you have to open another file to know why the expected number is 3, the test is unreadable and will be deleted rather than fixed.
|
|
93
|
+
- Unique-by-default for anything with a `UNIQUE` constraint — `f"user-{uuid4()}@example.test"`. Shared literals make two tests collide only when run in the same transaction, i.e. only in CI.
|
|
94
|
+
- Build the minimum. A factory that creates an org, a team, a project, and three users for a test about email formatting is a fixture wearing a factory's clothes.
|
|
95
|
+
- One exception: genuinely static reference data (currencies, country codes) is fine as a seed, because no test ever mutates it.
|
|
96
|
+
|
|
97
|
+
## 5. Testing Concurrency for Real
|
|
98
|
+
|
|
99
|
+
The only test that proves an idempotency key, a unique constraint, or a `SELECT ... FOR UPDATE` does anything. Fire the same request in parallel and assert the **database state**, not the responses.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const N = 8; // ≥8: two requests can be serialised by luck
|
|
103
|
+
const results = await Promise.allSettled(
|
|
104
|
+
Array.from({ length: N }, () =>
|
|
105
|
+
POST('/api/orders', body, { 'Idempotency-Key': 'k-1' })),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const { rows } = await db.query(
|
|
109
|
+
'SELECT count(*)::int AS n FROM orders WHERE idempotency_key = $1', ['k-1']);
|
|
110
|
+
expect(rows[0].n).toBe(1); // the real assertion: one row
|
|
111
|
+
expect(results.filter(r => status(r) === 201)).toHaveLength(1);
|
|
112
|
+
expect(results.filter(r => [200, 409].includes(status(r)))).toHaveLength(N - 1);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- **Two sequential-ish requests are not a race.** Use a barrier (`asyncio.gather`, a `threading.Barrier`, `Promise.all` over pre-built requests) and at least 8 in flight. A test where request two left 40 ms after request one passes against code with no protection at all.
|
|
116
|
+
- **Assert the row count, never only the status codes.** A handler can return one `201` and one `409` while having written two rows, if the `409` came from a second check after the insert.
|
|
117
|
+
- **Then assert the constraint exists**, because the database is what actually wins the race: `SELECT conname FROM pg_constraint WHERE conrelid = 'orders'::regclass`. Application-level "check then insert" loses under concurrency by construction.
|
|
118
|
+
- Test the retryable path too: with `SERIALIZABLE`, some of those requests fail with SQLSTATE `40001` and must be retried, not returned as `500`.
|
|
119
|
+
|
|
120
|
+
## 6. p50 / p95 / p99 — an Average Describes Nobody
|
|
121
|
+
|
|
122
|
+
Mean latency is the one number that belongs to no user. A service at 40 ms mean with a 2 s p99 is a service where the most engaged users — the ones with the most rows — are the ones hitting the tail.
|
|
123
|
+
|
|
124
|
+
| Percentile | Who it is |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| p50 | The typical request. Moves when the common path changes. |
|
|
127
|
+
| p95 | The user with real data. This is where missing indexes appear first. |
|
|
128
|
+
| p99 | The page a user actually notices and reports. |
|
|
129
|
+
| p99.9 | Pool waits, GC pauses, lock waits, cold caches. The incident, before it is one. |
|
|
130
|
+
|
|
131
|
+
**Tail latency compounds across fan-out.** A page that makes 20 backend calls has a `1 − 0.99²⁰ ≈ 18%` chance of touching at least one p99 call. A p99 the size of a timeout is therefore a p82 page-load problem.
|
|
132
|
+
|
|
133
|
+
- **Never average a percentile** across instances, pods, or time buckets — the arithmetic is meaningless. Aggregate the histogram: `histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))`. Instrumentation is [observability.md](observability.md).
|
|
134
|
+
- **Measure from the edge, not from inside the handler.** A timer that starts after the framework hands you the request excludes pool-acquire wait, event-loop queueing, and body read. The symptom is a well-known argument: "our metrics say 12 ms, users say 3 s." The users are right; your timer starts too late.
|
|
135
|
+
- Report p50/p95/p99 together, always with the request count. A p99 over 40 requests is one request.
|
|
136
|
+
|
|
137
|
+
## 7. Performance Triage, In This Order
|
|
138
|
+
|
|
139
|
+
Work down. Each row states how to **confirm** it, because the whole point is not to guess. Change one thing, re-measure, then move on.
|
|
140
|
+
|
|
141
|
+
| # | Suspect | Symptom | Confirm it |
|
|
142
|
+
| --- | --- | --- | --- |
|
|
143
|
+
| 1 | **Query count (N+1)** | Slow request, every query fast; latency scales with page size | Count queries per request; assert the count is constant across two page sizes ([data-modelling.md](data-modelling.md)) |
|
|
144
|
+
| 2 | **Missing index** | One slow query; p95 worse than p50 by 10× | `EXPLAIN (ANALYZE, BUFFERS)` shows `Seq Scan` on a large table, or `Rows Removed by Filter` in the thousands |
|
|
145
|
+
| 3 | **Payload size** | Time scales with bytes, not rows; mobile worse than desktop | `curl -w '%{size_download} %{time_total}\n'`; look for `SELECT *`, an uncapped `limit`, an embedded blob |
|
|
146
|
+
| 4 | **Serialisation cost** | Query time flat, total time rising with page size | Flamegraph / `py-spy` / `--cpu-prof`: time spent after the last query returns |
|
|
147
|
+
| 5 | **Pool exhaustion** | Bimodal latency; unrelated endpoints slow together; SQLSTATE `53300` | Pool wait-time metric > 0; `SELECT count(*), state FROM pg_stat_activity GROUP BY state` |
|
|
148
|
+
| 6 | **Chatty service calls** | Latency = sum of hops; tail dominated by one dependency | Trace span count per request; a request with 30 spans to one service is the answer |
|
|
149
|
+
| 7 | **Sync work that should be a job** | One endpoint slow, CPU or third-party bound, caller does not need the result | Time the handler section; if it is an email, a webhook, or a PDF, it is a job ([concurrency-and-jobs.md](concurrency-and-jobs.md)) |
|
|
150
|
+
|
|
151
|
+
```sql
|
|
152
|
+
-- Where the database's time actually goes. Requires the pg_stat_statements extension.
|
|
153
|
+
SELECT calls, round(total_exec_time) AS total_ms,
|
|
154
|
+
round(mean_exec_time::numeric,2) AS mean_ms, rows, query
|
|
155
|
+
FROM pg_stat_statements
|
|
156
|
+
ORDER BY total_exec_time DESC
|
|
157
|
+
LIMIT 20;
|
|
158
|
+
-- Read total_ms first, never mean_ms. The 0.4 ms query called 200 times per request
|
|
159
|
+
-- is the bug; the 90 ms report nobody runs is not.
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**One change per measurement.** Adding an index and rewriting the serialiser in one commit means you learn nothing, and the next person reverts both.
|
|
163
|
+
|
|
164
|
+
## 8. Connection Pool Sizing
|
|
165
|
+
|
|
166
|
+
**A bigger pool is usually slower.** The database has a fixed number of cores and disks; connections beyond that number do not add throughput, they add context switches, lock contention, and per-backend memory. Postgres forks a process per connection — 400 connections on an 8-core box is 400 processes fighting over 8 cores while each holds its own work memory.
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
per-instance pool = (cpu_cores * 2) + effective_spindle_count # HikariCP / PG wiki
|
|
170
|
+
hard ceiling: pool_size <= (max_connections - superuser_reserved_connections
|
|
171
|
+
- other_consumers) / instance_count
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Postgres defaults: `max_connections = 100`, `superuser_reserved_connections = 3`. Worked, for a 4-core database:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
formula : (4 * 2) + 1 (SSD) = 9 per instance
|
|
178
|
+
budget : 100 - 3 = 97 usable
|
|
179
|
+
other consumers : 8 job workers x 4 = 32
|
|
180
|
+
migration runner = 2
|
|
181
|
+
metrics exporter = 2
|
|
182
|
+
humans in psql = 5 -> 41 reserved
|
|
183
|
+
app instances : 6
|
|
184
|
+
ceiling : (97 - 41) / 6 = 9.3 -> pool_size = 9
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Cross-check with Little's Law: a request that holds a connection for 5 ms means one connection serves 200 rps, so a pool of 9 covers 1,800 rps of database work. If your traffic is 300 rps, a pool of 40 was never doing anything except making the stall worse.
|
|
188
|
+
|
|
189
|
+
- **Count every pool.** A read-replica pool, a worker pool, and the migration runner are separate consumers. The classic incident: "we scaled to 20 instances and everything broke" — 20 × 20 connections against `max_connections = 100`, so most instances got `53300` at boot.
|
|
190
|
+
- **Acquire timeout must be shorter than the request timeout**, so exhaustion fails fast with a `503` instead of queueing until every request times out at once. The concrete timeout values and their ordering belong to [deploy-and-config.md](deploy-and-config.md).
|
|
191
|
+
- **Beyond the ceiling, add PgBouncer in `transaction` mode, not connections.** Then respect what transaction pooling breaks: session state, `SET` (use `SET LOCAL` — see [auth-and-secrets.md](auth-and-secrets.md)), server-side prepared statements without protocol support, `LISTEN/NOTIFY`, and advisory locks held across statements.
|
|
192
|
+
- **Pool wait time is a first-class metric.** Without it, exhaustion is indistinguishable from a slow database, and the two fixes are opposites.
|
|
193
|
+
|
|
194
|
+
## 9. Caching
|
|
195
|
+
|
|
196
|
+
**Fix the query first.** A cache in front of a missing index hides the defect until the first cold start, then delivers it all at once. Cache to avoid *unavoidable* work, never to avoid work you have not looked at.
|
|
197
|
+
|
|
198
|
+
### Key design — the key contains everything that varies the answer
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
cache:v3:t:{tenant_id}:u:{user_id}:invoice:{id}:proj:{fields}:s:{schema_version}
|
|
202
|
+
│ │ │ │ │ └ bust the whole space on deploy
|
|
203
|
+
│ │ │ │ └ two projections are two answers
|
|
204
|
+
│ │ │ └ the entity
|
|
205
|
+
│ │ └ ANY per-principal filtering must be in the key, or it leaks
|
|
206
|
+
│ └ tenant scope, always
|
|
207
|
+
└ key format version — the only safe way to change key shape without a flush
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Never cache an authorization-dependent answer under a key that omits the principal.** The symptom is not slow, it is user B opening user A's dashboard — that is Boundary Checklist #1 wearing a cache costume, and it will be reported as a security incident, correctly. If the response varies by who is asking and you cannot put the asker in the key, do not cache it. The HTTP-layer equivalent: a response that varies by credentials needs `Cache-Control: private` and `Vary: Authorization` (RFC 9110 §12.5.5), or a shared proxy will do the leaking for you.
|
|
211
|
+
|
|
212
|
+
Never cached, at all: anything with `Set-Cookie`, a token, a "things I can see" list keyed by query alone, or a permission decision keyed by resource alone.
|
|
213
|
+
|
|
214
|
+
### TTL vs explicit invalidation
|
|
215
|
+
|
|
216
|
+
| The data | Strategy |
|
|
217
|
+
| --- | --- |
|
|
218
|
+
| Expensive, staleness tolerable, no clean write event (aggregates, third-party lookups) | TTL only, jittered |
|
|
219
|
+
| Single known writer that can emit an event (an entity you own) | **Delete on write, plus a TTL as the safety net** |
|
|
220
|
+
| Correctness-critical (permissions, balances, flags gating money) | Don't cache, or ≤5 s TTL with an explicit bust |
|
|
221
|
+
| Derived from many writers across services | TTL only — invalidation you cannot enumerate is invalidation you will miss |
|
|
222
|
+
|
|
223
|
+
**Invalidation without a TTL is permanent staleness waiting for one missed code path.** Always both. And **delete the key on write, never update it** — an update races with an in-flight recompute and can write a value older than the one it replaced.
|
|
224
|
+
|
|
225
|
+
### Stampede — the failure that only appears under load
|
|
226
|
+
|
|
227
|
+
A hot key expires; the N requests in flight all miss and all run the expensive query simultaneously. The symptom is a **latency spike at exactly the TTL interval**, worsening as traffic grows, and it looks like a database problem because the database is what falls over.
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
ttl = base_ttl * random.uniform(0.9, 1.1) # jitter: 10k keys warmed by one deploy must
|
|
231
|
+
# not all expire in the same second
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
// Single-flight: one computation per key per process; everyone else awaits it.
|
|
236
|
+
const inflight = new Map<string, Promise<T>>();
|
|
237
|
+
async function single(key: string, compute: () => Promise<T>) {
|
|
238
|
+
const running = inflight.get(key);
|
|
239
|
+
if (running) return running; // no second query, no second miss
|
|
240
|
+
const p = compute().finally(() => inflight.delete(key));
|
|
241
|
+
inflight.set(key, p);
|
|
242
|
+
return p;
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Per-process single-flight covers most of it. Across processes, take a short Redis lock — `SET lock:{key} {token} NX PX 5000` — where the loser serves the stale value or waits; Go has `x/sync/singleflight` built for exactly this. For a genuinely hot key, probabilistic early recomputation (XFetch, VLDB 2015) recomputes before expiry with rising probability, so the key is never cold.
|
|
247
|
+
|
|
248
|
+
### Negative caching
|
|
249
|
+
|
|
250
|
+
Cache the miss, briefly and distinctly — a `NOT_FOUND` sentinel with a 30 s TTL, never an empty string that a later read cannot tell from a miss. Without it, every lookup for a nonexistent id is a full uncached database hit, and an enumeration script becomes a denial of service that never touches a cached key. Two rules: **delete the negative key on insert**, or a just-created resource 404s for its whole TTL; and never negatively cache an *authorization* denial keyed without the principal.
|
|
251
|
+
|
|
252
|
+
**Hit rate is not the metric — p99 with the cache cold is.** A 98% hit rate at 200 rps still sends 4 rps down the miss path, and after a deploy it briefly sends all 200. Report the cold number or you have not designed the cache, only the happy path.
|
|
253
|
+
|
|
254
|
+
## 10. Load Testing That Means Something
|
|
255
|
+
|
|
256
|
+
| Rule | Why |
|
|
257
|
+
| --- | --- |
|
|
258
|
+
| **Model the real mix** | Take the read/write ratio and endpoint distribution from the access log. A guessed 50/50 mix tests a service you do not run. |
|
|
259
|
+
| **Real key distribution** | Uniform random ids over 10M rows gives a ~0% cache hit rate and a perfectly cold buffer pool. Real traffic is Zipf: a few hot keys. Uniform tests neither the cache nor the hot-row contention. |
|
|
260
|
+
| **Warm first, then measure** | Discard the first 60 s: cold pool, cold cache, cold plans, un-JITted code. A benchmark of your cold start is a benchmark of nothing you serve. |
|
|
261
|
+
| **Run ≥10 minutes** | Below that you never see GC pauses, connection recycling, TTL expiry waves, autovacuum, or the slow leak that ends the shift. |
|
|
262
|
+
| **Open model, constant arrival rate** | Closed-loop VUs wait for their own response, so a slowing service receives less load and the numbers look fine. That is **coordinated omission**, and it hides queueing collapse entirely. Use k6 `constant-arrival-rate`, wrk2, or Gatling's open model. |
|
|
263
|
+
| **Production-shaped data volume** | An index decision tested against 1,000 rows is untested; a seq scan on 1,000 rows is fast. |
|
|
264
|
+
| **Report the knee, not the max** | Ramp until p99 crosses the SLO and report the rps at that point. Max rps is the number where the service is already failing. |
|
|
265
|
+
|
|
266
|
+
## Never Ship
|
|
267
|
+
|
|
268
|
+
| Never | Instead |
|
|
269
|
+
| --- | --- |
|
|
270
|
+
| A mocked database, or SQLite standing in for Postgres | Testcontainers / ephemeral schema per test |
|
|
271
|
+
| A test suite built from `schema.sql` | Build it by running the migration chain |
|
|
272
|
+
| A shared seed dataset the tests assert against | Factories, unique by default |
|
|
273
|
+
| Mocking your own code | Call it |
|
|
274
|
+
| A stubbed client class for a third party | An HTTP-level stub that can time out and return `503` |
|
|
275
|
+
| `freezegun` / global fake timers | An injected clock |
|
|
276
|
+
| A "race" test with two sequential requests | Barrier, ≥8 in flight, assert the row count |
|
|
277
|
+
| An average latency in a performance claim | p50 / p95 / p99 with the request count |
|
|
278
|
+
| An averaged p99 across instances | `histogram_quantile` over summed buckets |
|
|
279
|
+
| A perf fix with no before/after number | One change, measured with the command named |
|
|
280
|
+
| Raising the pool size to fix latency | Measure pool wait time; the fix is usually a smaller pool |
|
|
281
|
+
| Total pool across instances > `max_connections` | The formula and ceiling in §8 |
|
|
282
|
+
| A cache key without the principal on a per-user answer | Principal in the key, or no cache |
|
|
283
|
+
| A TTL with no jitter, or a hot key with no single-flight | Jitter + single-flight |
|
|
284
|
+
| Invalidation with no TTL safety net | Delete on write **and** a TTL |
|
|
285
|
+
| Caching before reading the query plan | Index first, cache second |
|
|
286
|
+
| A 30-second closed-loop load test on uniform random ids | §10 |
|
|
287
|
+
|
|
288
|
+
## No Guessing
|
|
289
|
+
|
|
290
|
+
- **Never state a latency, hit rate, or throughput you did not measure.** Name the command and the run length beside the number.
|
|
291
|
+
- **Never claim a cache will handle it** without stating the cold-cache p99 and the miss rate at target rps.
|
|
292
|
+
- **Never name a pool size** without `max_connections`, the instance count, and every other consumer.
|
|
293
|
+
- **Never declare a fix from one run.** Tail latency needs volume; compare distributions before and after, not two single requests.
|
|
294
|
+
- **Never assert an index is used, or a query is the slow one, from reading code.** `EXPLAIN (ANALYZE, BUFFERS)` and `pg_stat_statements` are the sources.
|
|
295
|
+
|
|
296
|
+
## Output
|
|
297
|
+
|
|
298
|
+
Write the tests, or the fix. Then, briefly: which layer the test sits in and what real dependency it runs against; for a perf change, the metric before and after with the exact command that produced both, and the **one** thing you changed; for a cache, the key template, the TTL, the invalidation trigger, and the stampede protection. If the honest answer was "no new test — this is the framework's behaviour" or "don't cache this, it needs an index", say that in one line and add nothing.
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Validation and Errors
|
|
2
|
+
|
|
3
|
+
This file owns two boundaries: turning untrusted bytes into typed values, and turning a failure into a response. The **wire-level status code table and the response envelope** live in [api-contracts.md](api-contracts.md) — when a status code appears here it is restated from there, never re-derived. Injection, SSRF, CORS, and rate-limit policy are [security.md](security.md). Credential-shaped failures (which of `401`/`403`, token expiry, session revocation) are [auth-and-secrets.md](auth-and-secrets.md). What a failure writes to the log, and the trace id that ties it to a request, are [observability.md](observability.md). Retry *execution* — backoff, jitter, dead letters — is [concurrency-and-jobs.md](concurrency-and-jobs.md); this file only decides whether a failure is retryable and how that decision gets on the wire.
|
|
4
|
+
|
|
5
|
+
`dasar-vibecode` §8 already establishes the philosophy: fail closed for money, permissions, and data integrity, and classify a retry on a **code**, never on a message string. Everything below is the backend implementation of that rule — the taxonomy those codes come from, and the response hygiene that keeps the code the only thing a client can branch on.
|
|
6
|
+
|
|
7
|
+
## Operating Posture
|
|
8
|
+
|
|
9
|
+
Two failure modes, and the first is worse:
|
|
10
|
+
|
|
11
|
+
1. **A value that was never parsed reaches business logic.** It looks like working code, passes every test written against valid input, and fails on the day someone sends `{"amount": "10", "role": "admin"}`. The whole first half of this file exists to make that state unrepresentable rather than merely unlikely.
|
|
12
|
+
2. **A failure loses its identity on the way out.** A `409` caught by a bare `except` and re-raised as a `500`, an upstream's error text pasted into your body, a retryable timeout that the client cannot tell from a permanent rejection. The second half exists to stop that.
|
|
13
|
+
|
|
14
|
+
The most common validation bug is not a missing check. It is a value that *was* checked in the handler and then re-read from the raw request body three lines later — `req.body.email` after `parsed.email` was already produced. The check was real; it just wasn't load-bearing.
|
|
15
|
+
|
|
16
|
+
## Parse, Do Not Validate
|
|
17
|
+
|
|
18
|
+
One schema per endpoint, at the edge. It is the *only* thing that touches the raw body, and the application's types are **derived from it** so that an unparsed value has no type to travel in.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// The schema is the source of truth. The type is downstream of it, never beside it.
|
|
22
|
+
const CreateOrder = z.object({
|
|
23
|
+
customerId: z.string().uuid(),
|
|
24
|
+
currency: z.enum(["IDR", "USD"]), // closed set, not z.string()
|
|
25
|
+
amountMinor: z.number().int().positive().max(100_000_000),
|
|
26
|
+
note: z.string().max(2_000).optional(),
|
|
27
|
+
}).strict(); // unknown key => reject, see below
|
|
28
|
+
|
|
29
|
+
type CreateOrder = z.infer<typeof CreateOrder>; // NOT a hand-written interface
|
|
30
|
+
|
|
31
|
+
// The handler receives the parsed value and never sees the request again.
|
|
32
|
+
async function createOrder(input: CreateOrder, ctx: Ctx) { /* ... */ }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
class CreateOrder(BaseModel):
|
|
37
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=False)
|
|
38
|
+
customer_id: UUID
|
|
39
|
+
currency: Literal["IDR", "USD"]
|
|
40
|
+
amount_minor: int = Field(gt=0, le=100_000_000)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Three rules that make this real rather than decorative:
|
|
44
|
+
|
|
45
|
+
- **`z.infer` / `model_dump`, never a parallel `interface`.** A hand-written type beside a schema drifts, and the drift is silent: the type says the field is required, the schema says optional, and production disagrees with the compiler.
|
|
46
|
+
- **The raw request object does not leave the edge function.** If `req`, `request.json()`, or `c.Request` is reachable from a service function, the parse is advisory. Pass the parsed value; pass nothing else.
|
|
47
|
+
- **Narrow past the primitives.** A `string` that is really a tenant-scoped id should be branded (`type OrderId = string & {__brand:"OrderId"}`, `NewType("OrderId", str)`) so an author id cannot be passed where an order id belongs. Money follows the hub's rule — integer minor units plus a currency, never a float, never a bare `number` called `amount`.
|
|
48
|
+
|
|
49
|
+
**Permission to write nothing:** if the framework already parses into a typed model and rejects unknown fields (FastAPI + pydantic with `extra="forbid"`, a Go handler decoding with `DisallowUnknownFields`), add no second layer. Two validators eventually disagree, and the looser one is the one an attacker finds. Extend the existing schema; do not hand-roll a checker beside it.
|
|
50
|
+
|
|
51
|
+
## Reject Unknown Fields by Default
|
|
52
|
+
|
|
53
|
+
`.strict()` in zod, `extra="forbid"` in pydantic, `dec.DisallowUnknownFields()` in Go, `ActiveModel` strong parameters in Rails. Default is reject; allowing an unknown key is a per-endpoint decision with a reason next to it.
|
|
54
|
+
|
|
55
|
+
Permissive parsing is how you get **mass assignment**. The mechanism is not exotic: the parser keeps the extra key, some layer below spreads the object into an update, and the client has just written a column it was never offered. In March 2012 Egor Homakov used exactly this against GitHub's Rails app and added his own key to the `rails/rails` repository. The bug was not in the update; it was in a parser that kept a field nobody had declared.
|
|
56
|
+
|
|
57
|
+
Two related traps:
|
|
58
|
+
|
|
59
|
+
- **Never `Object.assign(record, req.body)` or `Model(**data)` from untrusted input**, even after validating. Assign field by field from the parsed value. A schema that permits `role` on a different endpoint is one refactor away from permitting it here.
|
|
60
|
+
- **Response models are separate types from request models.** Reusing one model for both is how internal columns (`password_hash`, `stripe_customer_id`, `internal_notes`) become fields the client can read *and* set. See the response hygiene section.
|
|
61
|
+
|
|
62
|
+
## Hard Limits Every Endpoint Needs
|
|
63
|
+
|
|
64
|
+
Every one of these is a denial-of-service gate, and each stops a different attack. Take the numbers from here or from the project's own config — **never invent a limit** and never write "a sensible maximum".
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
max request body (JSON) 256 KiB # nginx client_max_body_size defaults to 1m;
|
|
68
|
+
# Express body-parser defaults to 100kb. 256 KiB
|
|
69
|
+
# covers real JSON payloads and is enforced at the
|
|
70
|
+
# proxy AND in-process, because only one of them
|
|
71
|
+
# is in front of you in every deployment.
|
|
72
|
+
max request body (upload) 25 MiB # streamed to disk/object storage, never buffered
|
|
73
|
+
# in memory; multipart parsers buffer by default.
|
|
74
|
+
max array length per field 100 # matches the page-size cap in api-contracts.md.
|
|
75
|
+
# Stops "one request, 50,000 IN(...) parameters"
|
|
76
|
+
# and the O(n^2) validator behind it.
|
|
77
|
+
max string length (scalar field) 1 KiB # ids, emails, names, enum-ish values
|
|
78
|
+
max string length (free text) 10 KiB # notes, descriptions, comments
|
|
79
|
+
max JSON nesting depth 8 # Python's json raises RecursionError past
|
|
80
|
+
# sys.getrecursionlimit() (default 1000): a
|
|
81
|
+
# 5,000-deep array is a stack crash, not a 400.
|
|
82
|
+
max query parameters 100 # hash-flooding (CVE-2011-4885 and siblings hit
|
|
83
|
+
# PHP, Ruby, Java, Python at once). Note qs's
|
|
84
|
+
# arrayLimit defaults to 20 and parameterLimit
|
|
85
|
+
# to 1000 — a[100000]=x is a 100k-element array.
|
|
86
|
+
request handler timeout 10s # shorter than the caller's; see deploy-and-config.md
|
|
87
|
+
db statement_timeout 5s # a query that outlives the request is pure waste
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Two more that are not size limits but belong to the same gate:
|
|
91
|
+
|
|
92
|
+
- **Cap the input length *before* any regex touches it.** A catastrophic-backtracking pattern is only exploitable if the attacker controls the length. `max(320)` on an email field turns a ReDoS into arithmetic.
|
|
93
|
+
- **Decompression is a limit too.** If you accept `Content-Encoding: gzip`, cap the *decompressed* size, not the compressed one. A 1 MiB gzip bomb expands past 1 GiB.
|
|
94
|
+
|
|
95
|
+
**Never write an email regex from memory.** RFC 5322's grammar does not fit in a pattern you can review, and every hand-rolled version rejects real addresses. Check for a single `@`, a non-empty local part, a domain containing a dot, cap the length at 320 bytes, then prove the address by sending mail to it. Deliverability is the only real validation.
|
|
96
|
+
|
|
97
|
+
## Normalise Before You Validate — and Know Where That Is the Bug
|
|
98
|
+
|
|
99
|
+
Order is fixed, because each step depends on the last:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
1. size check on the RAW bytes # NFKC can expand input; check before you grow it
|
|
103
|
+
2. decode UTF-8, reject invalid # not U+FFFD replacement — reject, an invalid encoding
|
|
104
|
+
# is the first half of every filter-bypass
|
|
105
|
+
3. Unicode normalise (NFC for text, NFKC for identifiers)
|
|
106
|
+
4. trim leading/trailing whitespace, collapse internal runs where the field is a name
|
|
107
|
+
5. validate against the schema
|
|
108
|
+
6. store the ORIGINAL form; index a derived normalised column for comparison
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Here is where normalisation itself becomes the vulnerability.** Case-folding and compatibility normalisation are many-to-one: they map distinct inputs onto one value. If that value is an *identity*, you have merged two accounts. Spotify's 2013 postmortem ("Creative usernames and Spotify account hijacking") is the canonical writeup: a username canonicalised at one layer and not another let an attacker recover someone else's account. The Turkish dotless `ı`, `İ` (U+0130), the Kelvin sign `K` folding to `k`, and `fi` (U+FB01) folding to `fi` are all live cases in any username field.
|
|
112
|
+
|
|
113
|
+
The rule that follows:
|
|
114
|
+
|
|
115
|
+
- **Normalisation for comparison is not normalisation for storage.** Store what the user typed. Compute a canonical form into a separate column, and put the `UNIQUE` constraint on *that* column (the hub's rule: constraints belong in the database).
|
|
116
|
+
- **A canonical collision is a rejection, not a merge.** If the normalised form already exists, return `409` at signup. Silently attaching the login to the existing account is the hijack.
|
|
117
|
+
- **Never case-fold a password, a token, a signature, or a code.** Fold identifiers; leave secrets byte-exact. Compare secrets with a constant-time function ([auth-and-secrets.md](auth-and-secrets.md)).
|
|
118
|
+
- **Never trim a password.** Trimming changes the credential and quietly breaks a user whose manager stored a trailing space.
|
|
119
|
+
|
|
120
|
+
## The Error Taxonomy
|
|
121
|
+
|
|
122
|
+
Every failure in the service resolves to exactly one of these classes before it reaches the response layer. The class determines all four downstream columns; the handler decides none of them.
|
|
123
|
+
|
|
124
|
+
| Class | Status | Log | Retryable | Detail safe to return |
|
|
125
|
+
|---|---|---|---|---|
|
|
126
|
+
| **Malformed input** — unparseable body, bad content type, bad JSON | `400` (`415` on media type) | `info` | No | Yes — the parse error position, never the parsed value |
|
|
127
|
+
| **Semantic validation** — well-formed, wrong: unknown enum, negative amount, failing `CHECK` (Postgres `23514`) | `422` | `info` | No | Yes — field path + rule, **never the value** (may be a secret) |
|
|
128
|
+
| **Payload too large** — over any limit above | `413` | `info` | No | Yes — the limit and the unit |
|
|
129
|
+
| **Unauthenticated** — no/expired/invalid credential | `401` | `info` | No (until a new credential) | Generic only. Never "no such user" vs "wrong password" |
|
|
130
|
+
| **Unauthorised** — authenticated, not permitted | `403`, or `404` where existence itself is private | `warn` | No | Generic. Name the required permission only if the caller could already know it exists |
|
|
131
|
+
| **Not found** — absent, or present and not yours | `404` | `info` | No | Yes — resource type and the id *they sent* |
|
|
132
|
+
| **Conflict** — unique violation (`23505`), state machine says no | `409` | `info` | No — the same request conflicts again | Yes — which constraint in business terms ("email already registered"), never the index name |
|
|
133
|
+
| **Precondition failed** — `If-Match`/version mismatch | `412` | `info` | No — client must re-read first | Yes — current version/ETag |
|
|
134
|
+
| **Serialization failure / deadlock** — Postgres `40001`, `40P01` | `409` (or `503` if you retry server-side) | `warn` | **Yes**, immediately, with jitter | Generic — "concurrent update, retry" |
|
|
135
|
+
| **Rate limited** | `429` | `info` | **Yes**, after `Retry-After` | Yes — limit, window, reset |
|
|
136
|
+
| **Dependency failure** — upstream `5xx`, connection refused, pool exhausted (`53300`) | `502` | `error` | **Yes**, capped | Generic — **never the upstream's own error text** |
|
|
137
|
+
| **Timeout** — upstream slow, statement cancelled (`57014`) | `504` | `error` | **Yes if the operation is idempotent**, otherwise No | Generic — "upstream timed out" |
|
|
138
|
+
| **Shedding / draining** — overload, shutdown in progress | `503` + `Retry-After` | `warn` | **Yes** | Generic |
|
|
139
|
+
| **Bug** — anything unhandled | `500` | `error` + trace id | **No.** Never advertise retryable on a failure you do not understand | Trace id and nothing else |
|
|
140
|
+
|
|
141
|
+
Two notes that carry most of the value:
|
|
142
|
+
|
|
143
|
+
- **The most common error-handling bug is not the missing `try`/`catch` — it is the `catch` that turns a `409` into a `500`.** A broad handler that wraps a typed domain error into a generic one destroys the class, and with it the status, the log level, and the client's ability to retry. Catch *narrowly*, or re-raise anything that already carries a class.
|
|
144
|
+
- **Postgres SQLSTATEs are your classifier, not the message.** Branch on `23505`, `23503`, `23514`, `40001`, `40P01`, `57014`, `53300` — never on `err.message.includes("duplicate")`, which is locale- and version-dependent. This is the hub's "classify on a code, not a message" made concrete.
|
|
145
|
+
|
|
146
|
+
## Retryability Is Signalled, Never Inferred
|
|
147
|
+
|
|
148
|
+
A client that guesses retryability from the status code is guessing. `500` is sometimes transient and sometimes a permanent bug; `409` is permanent for a unique violation and transient for a serialization failure. So say it, in the body and in the headers, using the RFC 9457 envelope the hub mandates plus two extension members:
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
{
|
|
152
|
+
"type": "https://api.example.com/problems/serialization-failure",
|
|
153
|
+
"title": "Concurrent update",
|
|
154
|
+
"status": 409,
|
|
155
|
+
"detail": "Another request modified this order. Re-read it and reapply your change.",
|
|
156
|
+
"instance": "/orders/9f2c…/confirm",
|
|
157
|
+
"code": "ORDER_CONCURRENT_UPDATE",
|
|
158
|
+
"retryable": true,
|
|
159
|
+
"retry_after_ms": 250
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
Retry-After: 1 # RFC 9110 §10.2.3: delay-seconds or an HTTP-date. Send it on
|
|
165
|
+
# every 429 and every 503. Derive the number from the limiter's
|
|
166
|
+
# window or the drain deadline — never pick one because it looks calm.
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- `code` is a **stable enum defined in one file**, screaming snake case, never constructed at the call site. **Never invent a code inline.** A typo'd code is a client branch that will never fire again, and you will not find out.
|
|
170
|
+
- `retryable` is a boolean the *server* computes from the class table. It is not "did this look transient".
|
|
171
|
+
- Retries are only safe on idempotent operations. If the endpoint is not idempotent, `retryable` is `false` regardless of class until an idempotency key makes it safe ([concurrency-and-jobs.md](concurrency-and-jobs.md)).
|
|
172
|
+
- **Never invent a status code that "feels right".** `499` is nginx's internal client-closed-request and is not a real HTTP status; `509` is a cPanel extension; `418` is a joke from RFC 2324. If it is not in the hub's table or [api-contracts.md](api-contracts.md), it does not go on the wire.
|
|
173
|
+
|
|
174
|
+
## What Never Appears in a Response Body
|
|
175
|
+
|
|
176
|
+
Stack traces · SQL text or query plans · internal primary keys or row ids · upstream error text verbatim · file paths · library or framework versions · hostnames and internal service names · environment variable names · index and constraint identifiers · the value that failed validation.
|
|
177
|
+
|
|
178
|
+
Every one of those is reconnaissance. The upstream text is the sharpest: it frequently carries *its* internal ids, *its* stack, and occasionally a credential fragment, and forwarding it makes your service a proxy for someone else's leak.
|
|
179
|
+
|
|
180
|
+
Raw, and what it hands an attacker — schema names, ORM, file layout, and the fact that this table has a `users_email_key`:
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{ "error": "IntegrityError: duplicate key value violates unique constraint \"users_email_key\"\n DETAIL: Key (lower(email))=(a@b.com) already exists.\n at /srv/app/services/users.py:214 in create_user\n SQLAlchemy 2.0.30 / psycopg 3.1.18" }
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Sanitised — same information for the caller, none for the attacker:
|
|
187
|
+
|
|
188
|
+
```json
|
|
189
|
+
{
|
|
190
|
+
"type": "https://api.example.com/problems/email-taken",
|
|
191
|
+
"title": "Email already registered",
|
|
192
|
+
"status": 409,
|
|
193
|
+
"detail": "An account already exists for this email address. Sign in, or reset the password.",
|
|
194
|
+
"code": "USER_EMAIL_TAKEN",
|
|
195
|
+
"retryable": false,
|
|
196
|
+
"instance": "/users",
|
|
197
|
+
"trace_id": "01J8ZP4K9M7QX2"
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The raw version goes to the log, keyed by that same `trace_id`, with secrets redacted before writing ([observability.md](observability.md)). **The default response for an unclassified exception is `500` + `code` + `trace_id`, with the message discarded** — a framework that echoes `str(exc)` by default is a leak waiting for the first unusual exception, and `DEBUG=true` in production is that leak on purpose.
|
|
202
|
+
|
|
203
|
+
Log hygiene has one trap of its own: do not log the raw body of a *validation* failure. The field that failed is safe; the body containing `password`, `card_number`, or a token is not.
|
|
204
|
+
|
|
205
|
+
## Partial Failure
|
|
206
|
+
|
|
207
|
+
A bulk endpoint has to decide this before it is written, because it is a contract, not an implementation detail.
|
|
208
|
+
|
|
209
|
+
| Situation | Answer |
|
|
210
|
+
|---|---|
|
|
211
|
+
| Items share an invariant (a transfer's debit and credit; a batch that must not half-apply) | **All-or-nothing.** One transaction, one status. First failure aborts. |
|
|
212
|
+
| Items are independent and the client can act per-item (import 500 contacts, send 100 notifications) | **Per-item results**, `207 Multi-Status` (RFC 4918 §13) or `200` with a `results` array |
|
|
213
|
+
| Items are independent but the caller is a dumb client that only reads the status line | **All-or-nothing.** A partial success it cannot inspect is a silent data-loss bug |
|
|
214
|
+
| The batch is large or slow (> ~10s of work) | Neither — accept it, return `202` with a job id, report per-item outcomes on the job resource |
|
|
215
|
+
|
|
216
|
+
When you return per-item results, three non-negotiables: every item carries the **client's own id** (echo the request-supplied key; never a positional index, which breaks the instant a client reorders), every failed item carries the **full problem object** with its `code` and `retryable`, and the endpoint is **idempotent per item** so the client can resubmit only the failures. A partial-success response the client cannot safely re-drive is worse than a hard failure.
|
|
217
|
+
|
|
218
|
+
**Never return `200` with a body that quietly reports failures** and no per-item structure. That is the hub's "never `200` with an error body" in its most dangerous form: monitoring sees success, and the missing rows surface a month later.
|
|
219
|
+
|
|
220
|
+
## Messages That Tell the Consumer What To Do
|
|
221
|
+
|
|
222
|
+
`dasar-vibecode` requires an error to say what failed and why. For an API, extend it by one clause, because **your consumer cannot read your logs**: what they should do next.
|
|
223
|
+
|
|
224
|
+
| Bad | Good |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `"Invalid request"` | `"currency must be one of IDR, USD; received JPY"` |
|
|
227
|
+
| `"Validation failed"` | `"items must contain at most 100 entries; received 512. Split the request."` |
|
|
228
|
+
| `"Unauthorized"` | `"Access token expired. Refresh at POST /auth/refresh and retry."` |
|
|
229
|
+
| `"Conflict"` | `"Order is already confirmed and cannot be confirmed again."` |
|
|
230
|
+
| `"Internal server error"` | `"Unexpected error. Retry is not safe; quote trace 01J8ZP4K9M7QX2 to support."` |
|
|
231
|
+
|
|
232
|
+
Field errors ride in an `errors` array with a **JSON Pointer** path (RFC 6901) so a client can attach a message to an input without string-matching:
|
|
233
|
+
|
|
234
|
+
```json
|
|
235
|
+
"errors": [
|
|
236
|
+
{ "pointer": "/items/3/quantity", "code": "OUT_OF_RANGE", "detail": "must be between 1 and 99" }
|
|
237
|
+
]
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Echo the field path and the rule. **Never echo the offending value** — validation failures are exactly where secrets get typed into the wrong box.
|
|
241
|
+
|
|
242
|
+
**Permission to add nothing:** most endpoints need no new error code. Reach for an existing one first; a taxonomy with forty codes is a taxonomy no client branches on. Adding `ORDER_ITEM_QUANTITY_TOO_HIGH` beside a working `OUT_OF_RANGE` is a defect, not thoroughness.
|
|
243
|
+
|
|
244
|
+
## Never Ship
|
|
245
|
+
|
|
246
|
+
| Never | Instead |
|
|
247
|
+
|---|---|
|
|
248
|
+
| A hand-written type beside a schema | `z.infer` / the model class as the only type |
|
|
249
|
+
| Reading `req.body` after parsing | Pass the parsed value; the raw request stops at the edge |
|
|
250
|
+
| A permissive parser (`extra="allow"`, no `.strict()`) | Reject unknown fields; allow one only with a written reason |
|
|
251
|
+
| `Object.assign(row, input)` / `Model(**data)` | Field-by-field assignment from the parsed value |
|
|
252
|
+
| One model for request and response | Separate types; response models omit internal columns |
|
|
253
|
+
| An endpoint with no body-size or array-length cap | The numbers in the limits block |
|
|
254
|
+
| A regex over an uncapped string | Cap the length first |
|
|
255
|
+
| Case-folding two identities into one | Canonical column + `UNIQUE` + `409` on collision |
|
|
256
|
+
| Branching on `err.message` | SQLSTATE / typed error class / `code` |
|
|
257
|
+
| A broad `catch` that re-raises as `500` | Catch narrowly; re-raise anything already classified |
|
|
258
|
+
| `retryable` inferred by the client | Server-computed flag + `Retry-After` |
|
|
259
|
+
| A status code not in the hub's table (`499`, `509`, `418`) | The class table above |
|
|
260
|
+
| Upstream error text, SQL, stack, or path in a body | `code` + `trace_id`; the detail goes to the log |
|
|
261
|
+
| `200` with hidden per-item failures | `207` / per-item results, or all-or-nothing |
|
|
262
|
+
| A new error code for a case an existing one covers | The existing code |
|
|
263
|
+
|
|
264
|
+
## Output
|
|
265
|
+
|
|
266
|
+
Write the schema and the error mapping. Then, briefly: the **parse boundary** (which schema, and that unknown fields are rejected), the **limits** you applied and where they are enforced, the **classes** this endpoint can produce with their status codes, and one line on **what a client sees on the worst failure** — the exact sanitised body. If you removed a redundant validator or declined to add an error code, say so in one line. The code is the deliverable; do not expand this into a report.
|