|
1 | | -# Plan: `wait_for` Feature - DX Design |
| 1 | +# Plan: Better Error Messages & Debuggability |
2 | 2 |
|
3 | | -## Overview |
4 | | - |
5 | | -A new `wait_for` block that repeatedly polls an endpoint until a condition is satisfied. Designed for async server operations that require time to complete (job processing, webhooks, eventual consistency, etc.). |
6 | | - |
7 | | -## DX Design |
8 | | - |
9 | | -### Basic Syntax |
10 | | - |
11 | | -```yaml |
12 | | -yapi: v1 |
13 | | -url: ${url}/jobs/${job_id} |
14 | | -method: GET |
15 | | - |
16 | | -wait_for: |
17 | | - until: |
18 | | - - .status == "completed" |
19 | | - period: 2s |
20 | | - timeout: 60s |
21 | | -``` |
22 | | -
|
23 | | -### Fixed Period (Simple) |
24 | | -
|
25 | | -```yaml |
26 | | -wait_for: |
27 | | - until: |
28 | | - - .status == "completed" |
29 | | - period: 2s # Fixed time between attempts |
30 | | - timeout: 60s # Total time limit |
31 | | -``` |
32 | | -
|
33 | | -### Exponential Backoff |
34 | | -
|
35 | | -```yaml |
36 | | -wait_for: |
37 | | - until: |
38 | | - - .status == "completed" |
39 | | - backoff: |
40 | | - seed: 1s # Initial wait time |
41 | | - multiplier: 2 # Each attempt waits multiplier * previous |
42 | | - timeout: 60s # Total time limit |
43 | | -``` |
44 | | -
|
45 | | -Backoff example with `seed: 1s, multiplier: 2`: |
46 | | -- Attempt 1 → wait 1s |
47 | | -- Attempt 2 → wait 2s |
48 | | -- Attempt 3 → wait 4s |
49 | | -- Attempt 4 → wait 8s |
50 | | -- ...continues until timeout |
51 | | - |
52 | | -### Behavior |
53 | | - |
54 | | -1. Execute the request |
55 | | -2. If `until` conditions pass → success, stop polling |
56 | | -3. If `until` conditions fail OR request errors (5xx, network) → wait (period or backoff), retry |
57 | | -4. If `timeout` exceeded → fail with timeout error |
58 | | - |
59 | | -**Error handling**: Intermediate failures (5xx, network errors, 4xx) are treated as "not ready yet" and polling continues. Only timeout causes failure. |
60 | | - |
61 | | -**Timing**: Either `period` OR `backoff` must be specified (mutually exclusive). |
| 3 | +## Context |
| 4 | +Implementing WISHLIST.md items #1, #2, and #4 (removing #3 since `yapi send` already exists). |
62 | 5 |
|
63 | 6 | --- |
64 | 7 |
|
65 | | -## Use Cases |
66 | | - |
67 | | -### 1. Single Request - Job Completion |
68 | | - |
69 | | -```yaml |
70 | | -yapi: v1 |
71 | | -url: ${url}/jobs/${job_id} |
72 | | -method: GET |
| 8 | +## 1. WISHLIST.md cleanup |
| 9 | +Remove item #3 (yapi send) since it's already shipped. |
73 | 10 |
|
74 | | -wait_for: |
75 | | - until: |
76 | | - - .status == "completed" or .status == "failed" |
77 | | - period: 2s |
78 | | - timeout: 120s |
79 | | -
|
80 | | -expect: |
81 | | - status: 200 |
82 | | - assert: |
83 | | - - .status == "completed" # Final assertion after wait_for succeeds |
84 | | -``` |
85 | | - |
86 | | -### 2. Chain Step - Async Workflow |
87 | | - |
88 | | -```yaml |
89 | | -yapi: v1 |
90 | | -chain: |
91 | | - - name: create_job |
92 | | - url: ${url}/jobs |
93 | | - method: POST |
94 | | - body: |
95 | | - type: "data_export" |
96 | | - expect: |
97 | | - status: 202 |
98 | | - assert: |
99 | | - - .job_id != null |
100 | | -
|
101 | | - - name: wait_for_job |
102 | | - url: ${url}/jobs/${create_job.job_id} |
103 | | - method: GET |
104 | | - wait_for: |
105 | | - until: |
106 | | - - .status == "completed" |
107 | | - backoff: |
108 | | - seed: 1s |
109 | | - multiplier: 2 |
110 | | - timeout: 300s |
111 | | - expect: |
112 | | - status: 200 |
113 | | - assert: |
114 | | - - .download_url != null |
115 | | -
|
116 | | - - name: download_result |
117 | | - url: ${wait_for_job.download_url} |
118 | | - method: GET |
119 | | - output_file: ./export.csv |
120 | | -``` |
121 | | - |
122 | | -### 3. Webhook/Callback Waiting |
| 11 | +--- |
123 | 12 |
|
124 | | -```yaml |
125 | | -yapi: v1 |
126 | | -chain: |
127 | | - - name: trigger_webhook |
128 | | - url: ${url}/webhooks/trigger |
129 | | - method: POST |
| 13 | +## 2. Warn on bare `$word.word` variable syntax (WISHLIST #1) |
130 | 14 |
|
131 | | - - name: check_received |
132 | | - url: ${url}/webhooks/received |
133 | | - method: GET |
134 | | - wait_for: |
135 | | - until: |
136 | | - - . | length > 0 |
137 | | - - .[0].payload.event == "user.created" |
138 | | - period: 1s |
139 | | - timeout: 30s |
140 | | -``` |
| 15 | +The problem: `$step.field` (no braces) silently passes as a literal string instead of being substituted. Only `${step.field}` works. |
141 | 16 |
|
142 | | -### 4. Database Eventual Consistency |
| 17 | +**Changes:** |
143 | 18 |
|
144 | | -```yaml |
145 | | -yapi: v1 |
146 | | -chain: |
147 | | - - name: create_user |
148 | | - url: ${url}/users |
149 | | - method: POST |
150 | | - body: |
151 | | - email: "test@example.com" |
152 | | - expect: |
153 | | - status: 201 |
| 19 | +- **`cli/internal/vars/vars.go`**: Add a `BareChainRef` regex that matches `$word.word` patterns that are NOT inside `${...}`. |
| 20 | +- **`cli/internal/vars/vars.go`**: Add `FindBareRefs(s string) []string` that returns the bare refs found. |
| 21 | +- **`cli/internal/validation/analyzer.go`**: In `analyzeParsed()`, call a new `warnBareChainRefs(text)` validation function that scans the raw YAML text for bare `$word.word` patterns and emits `SeverityWarning` diagnostics with line numbers and an actionable message like: |
| 22 | + `"possible bare variable reference '$step.field' -- did you mean '${step.field}'? Only the ${...} form is substituted."` |
154 | 23 |
|
155 | | - - name: verify_searchable |
156 | | - url: ${url}/users/search?email=test@example.com |
157 | | - method: GET |
158 | | - wait_for: |
159 | | - until: |
160 | | - - . | length == 1 |
161 | | - period: 500ms |
162 | | - timeout: 10s |
163 | | -``` |
| 24 | +This catches the problem at config analysis time (before execution), so users see the warning immediately -- even in `yapi validate`. |
164 | 25 |
|
165 | 26 | --- |
166 | 27 |
|
167 | | -## Interaction with Existing Features |
168 | | - |
169 | | -### With `expect` |
| 28 | +## 3. Show resolved request details in verbose chain execution (WISHLIST #2) |
170 | 29 |
|
171 | | -`wait_for` runs first. Once `until` conditions pass, `expect` runs on the final response: |
| 30 | +The problem: When a chain step fails, you can't see what values were actually sent because variable substitution is invisible. |
172 | 31 |
|
173 | | -```yaml |
174 | | -wait_for: |
175 | | - until: |
176 | | - - .status != "pending" # Wait until not pending |
177 | | - period: 1s |
178 | | - timeout: 30s |
| 32 | +**Changes:** |
179 | 33 |
|
180 | | -expect: |
181 | | - status: 200 |
182 | | - assert: |
183 | | - - .status == "completed" # Then verify it's completed (not failed) |
184 | | -``` |
| 34 | +- **`cli/internal/runner/runner.go`**: Add `Verbose bool` field to `runner.Options`. |
| 35 | +- **`cli/internal/runner/runner.go`**: In `RunChain()`, after `interpolateConfig()` succeeds and before executing, if `opts.Verbose` is true, print the resolved config to stderr: |
| 36 | + - Resolved URL (with method) |
| 37 | + - Resolved headers |
| 38 | + - Resolved body (JSON-serialized if map, or raw if string) |
| 39 | + - Uses `fmt.Fprintf(os.Stderr, ...)` with `[VERBOSE]` prefix, consistent with the existing Logger pattern. |
| 40 | +- **`cli/cmd/yapi/run.go`**: Set `opts.Verbose = ctx.verbose` when building runner.Options in `executeRunE()`. |
185 | 41 |
|
186 | | -### With `timeout` |
187 | | - |
188 | | -The existing `timeout` field is per-request. `wait_for.timeout` is total polling time: |
189 | | - |
190 | | -```yaml |
191 | | -timeout: 5s # Each poll attempt times out after 5s |
| 42 | +--- |
192 | 43 |
|
193 | | -wait_for: |
194 | | - until: |
195 | | - - .ready == true |
196 | | - period: 2s |
197 | | - timeout: 60s # Total polling time limit |
198 | | -``` |
| 44 | +## 4. Print step responses in verbose chain mode (WISHLIST #4) |
199 | 45 |
|
200 | | -### With `delay` |
| 46 | +The problem: In chain execution, you only see the final failing step's output, not intermediate step responses. |
201 | 47 |
|
202 | | -`delay` happens before `wait_for` starts: |
| 48 | +**Changes:** |
203 | 49 |
|
204 | | -```yaml |
205 | | -delay: 5s # Wait 5s before starting to poll |
| 50 | +- **`cli/internal/runner/runner.go`**: In `RunChain()`, after each step executes, if `opts.Verbose` is true, print the step's response details to stderr: |
| 51 | + - Status code |
| 52 | + - Response body (truncated at 1000 chars for readability) |
| 53 | + - Duration |
206 | 54 |
|
207 | | -wait_for: |
208 | | - until: |
209 | | - - .status == "done" |
210 | | - period: 2s |
211 | | - timeout: 30s |
212 | | -``` |
| 55 | + This replaces the need for a per-step `debug: true` field -- verbose mode shows everything, which is simpler and avoids new config surface area. |
213 | 56 |
|
214 | 57 | --- |
215 | 58 |
|
216 | | -## Output During Polling |
| 59 | +## 5. Example files: `examples/debugging/` |
217 | 60 |
|
218 | | -When running with verbose/default output: |
| 61 | +Create example `.yapi.yml` files that demonstrate the improved debugging experience: |
219 | 62 |
|
220 | | -``` |
221 | | -[POLL] Attempt 1 - conditions not met, retrying in 2s... |
222 | | -[POLL] Attempt 2 - conditions not met, retrying in 2s... |
223 | | -[POLL] Attempt 3 - request failed (503), retrying in 2s... |
224 | | -[POLL] Attempt 4 - conditions met! |
225 | | -``` |
| 63 | +- **`bare-variable-warning.yapi.yml`**: A chain that uses `$step.field` (bare) to trigger the new warning. |
| 64 | +- **`chain-verbose-demo.yapi.yml`**: A multi-step chain against jsonplaceholder with variables that shows how `--verbose` reveals resolved values. |
| 65 | +- **`assertion-failure-demo.yapi.yml`**: A request with an `expect:` block that will fail, showing the detailed assertion error output. |
| 66 | +- **`missing-key-demo.yapi.yml`**: A chain that references a nonexistent JSON key, showing the precise error path. |
226 | 67 |
|
227 | 68 | --- |
228 | 69 |
|
229 | | -## Config Schema |
230 | | - |
231 | | -```go |
232 | | -type Backoff struct { |
233 | | - Seed string `yaml:"seed"` // Initial wait, e.g., "1s" |
234 | | - Multiplier float64 `yaml:"multiplier"` // e.g., 2 |
235 | | -} |
236 | | - |
237 | | -type WaitFor struct { |
238 | | - Until []string `yaml:"until"` // Required: JQ assertions |
239 | | - Period string `yaml:"period,omitempty"` // Fixed interval, e.g., "2s" |
240 | | - Backoff *Backoff `yaml:"backoff,omitempty"` // Exponential backoff |
241 | | - Timeout string `yaml:"timeout"` // Required: total time limit |
242 | | -} |
243 | | -``` |
244 | | - |
245 | | -Added to `ConfigV1`: |
246 | | -```go |
247 | | -type ConfigV1 struct { |
248 | | - // ... existing fields ... |
249 | | - WaitFor *WaitFor `yaml:"wait_for,omitempty"` |
250 | | -} |
251 | | -``` |
252 | | - |
253 | | ---- |
| 70 | +## Files changed |
254 | 71 |
|
255 | | -## Validation Rules |
| 72 | +| File | Change | |
| 73 | +|------|--------| |
| 74 | +| `WISHLIST.md` | Remove item #3 | |
| 75 | +| `cli/internal/vars/vars.go` | Add `BareChainRef` regex + `FindBareRefs()` function | |
| 76 | +| `cli/internal/validation/analyzer.go` | Add `warnBareChainRefs()`, call from `analyzeParsed()` | |
| 77 | +| `cli/internal/runner/runner.go` | Add `Verbose` to `Options`, add verbose logging in `RunChain()` | |
| 78 | +| `cli/cmd/yapi/run.go` | Thread `verbose` into `runner.Options` | |
| 79 | +| `examples/debugging/*.yapi.yml` | 4 new example files | |
256 | 80 |
|
257 | | -1. `until` is required and must have at least one assertion |
258 | | -2. `timeout` is required and must be valid Go duration |
259 | | -3. Exactly one of `period` OR `backoff` must be specified (mutually exclusive) |
260 | | -4. If `period`: must be valid Go duration |
261 | | -5. If `backoff`: `seed` must be valid Go duration, `multiplier` must be > 1 |
262 | | -6. All `until` expressions must be valid JQ |
| 81 | +**No new dependencies. No config schema changes. No breaking changes.** |
0 commit comments