@flowapt/flowiq-cli 0.12.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/TEAM-GUIDE.md +2 -0
- package/package.json +1 -1
- package/src/http-retry.test.mjs +48 -0
- package/src/http.js +40 -1
package/README.md
CHANGED
|
@@ -2303,6 +2303,20 @@ flowiq woo get <org> products --all --fields id,name,permalink,status # past t
|
|
|
2303
2303
|
- Not a CLI fix: #186 (Shopify store analytics need the `read_reports` scope
|
|
2304
2304
|
on the FlowIQ app).
|
|
2305
2305
|
|
|
2306
|
+
### 0.12.1: dropped connections and `insights status <org>`
|
|
2307
|
+
|
|
2308
|
+
- **A read survives a dropped connection.** A bare `Request failed: fetch failed`
|
|
2309
|
+
(the connection dropped, often when several commands run at once) is retried
|
|
2310
|
+
twice with a short backoff on every GET. A write (POST / PUT / DELETE) is never
|
|
2311
|
+
repeated blind: it may have reached the server, so the error says to check the
|
|
2312
|
+
result before retrying. Measured on 2 Oct 2026: 30 of 57 month-by-month
|
|
2313
|
+
`shopify get --all` pulls failed this way five at a time on 0.11.0; twelve run
|
|
2314
|
+
six at a time on 0.12.1 all came back (6,538 orders, `flowiq gaps` #198).
|
|
2315
|
+
- **`flowiq insights status <org>` no longer times out.** The server used to
|
|
2316
|
+
count 30 days of messages for every org and keep one; it now counts only the
|
|
2317
|
+
org asked for (0.2 s), and the all-orgs listing reads each org on its own index
|
|
2318
|
+
(about 1 s for 60 orgs). Server-side, so it works on any CLI version (#130).
|
|
2319
|
+
|
|
2306
2320
|
### Guide — `flowiq guide`
|
|
2307
2321
|
|
|
2308
2322
|
Read the bundled docs in the terminal — no digging through node_modules.
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -459,6 +459,8 @@ Three things worth knowing:
|
|
|
459
459
|
| "Organization has no active_whatsapp_agent" | Target the agent directly: `--agent <id>` (ids from `flowiq agent list <org_id>`) |
|
|
460
460
|
| Login browser page says the code expired | Codes live 10 minutes — just re-run `flowiq auth login` |
|
|
461
461
|
| Pushed the wrong thing | Everything is pull→push, so re-pull an older copy if you have one, or check with Matt — server logs record every push with who/what/when |
|
|
462
|
+
| `could not reach the API after 3 tries` | The connection dropped (0.12.1 already retried the read twice). Run it again, and run fewer commands at once |
|
|
463
|
+
| `lost its connection … may still have finished the write` | A write is never retried for you: check the result (`list` / `status` / `pull`, or `flowiq audit`) before running it again |
|
|
462
464
|
|
|
463
465
|
ChatCart rollout: Pick n Pay uses retailer `pnp` and the private connection link.
|
|
464
466
|
Enable its gateway before adding it to the agent tools. Cart retries use
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// node --test src/http-retry.test.mjs
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { fetchWithRetry, isNetworkError } from "./http.js";
|
|
5
|
+
|
|
6
|
+
const dropped = () => Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } });
|
|
7
|
+
const noSleep = async () => {};
|
|
8
|
+
|
|
9
|
+
test("isNetworkError: a dropped connection yes, a programming error no", () => {
|
|
10
|
+
assert.equal(isNetworkError(dropped()), true);
|
|
11
|
+
assert.equal(isNetworkError(new TypeError("Invalid URL")), false);
|
|
12
|
+
assert.equal(isNetworkError(new Error("boom")), false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("fetchWithRetry: a GET is retried after a dropped connection", async () => {
|
|
16
|
+
let calls = 0;
|
|
17
|
+
const fetchImpl = async () => { calls++; if (calls < 3) throw dropped(); return { ok: true }; };
|
|
18
|
+
const resp = await fetchWithRetry("u", {}, { method: "GET", endpoint: "store-api", fetchImpl, sleep: noSleep });
|
|
19
|
+
assert.deepEqual(resp, { ok: true });
|
|
20
|
+
assert.equal(calls, 3);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("fetchWithRetry: a GET gives up after 3 tries and says it is the network", async () => {
|
|
24
|
+
let calls = 0;
|
|
25
|
+
const fetchImpl = async () => { calls++; throw dropped(); };
|
|
26
|
+
await assert.rejects(
|
|
27
|
+
fetchWithRetry("u", {}, { method: "GET", endpoint: "store-api", fetchImpl, sleep: noSleep }),
|
|
28
|
+
(e) => e.code === "ENETWORK" && /after 3 tries/.test(e.message) && /ECONNRESET/.test(e.message)
|
|
29
|
+
);
|
|
30
|
+
assert.equal(calls, 3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("fetchWithRetry: a write is never repeated and says check before retrying", async () => {
|
|
34
|
+
let calls = 0;
|
|
35
|
+
const fetchImpl = async () => { calls++; throw dropped(); };
|
|
36
|
+
await assert.rejects(
|
|
37
|
+
fetchWithRetry("u", {}, { method: "POST", endpoint: "prompts", fetchImpl, sleep: noSleep }),
|
|
38
|
+
(e) => e.code === "ENETWORK" && /may still have finished the write/.test(e.message)
|
|
39
|
+
);
|
|
40
|
+
assert.equal(calls, 1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("fetchWithRetry: a non-network error is not retried", async () => {
|
|
44
|
+
let calls = 0;
|
|
45
|
+
const fetchImpl = async () => { calls++; throw new TypeError("Invalid URL"); };
|
|
46
|
+
await assert.rejects(fetchWithRetry("u", {}, { method: "GET", fetchImpl, sleep: noSleep }), /Invalid URL/);
|
|
47
|
+
assert.equal(calls, 1);
|
|
48
|
+
});
|
package/src/http.js
CHANGED
|
@@ -118,6 +118,45 @@ export function isVercelChallenge(status, headers, text) {
|
|
|
118
118
|
return (status === 403 || status === 429) && /Vercel Security Checkpoint|vercel-challenge|_vercel\/challenge/i.test(text || "");
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// A dropped connection surfaces from undici as a bare "TypeError: fetch failed"
|
|
122
|
+
// (the real reason sits in err.cause). Several CLI calls in parallel hit it
|
|
123
|
+
// (2 Oct 2026: 30 of 57 month-by-month `shopify get --all` pulls, `flowiq gaps`
|
|
124
|
+
// #198), and every one succeeded when simply re-run. A read is safe to repeat,
|
|
125
|
+
// so GETs retry twice with a short backoff; a write is never repeated blind,
|
|
126
|
+
// because it may have reached the server before the connection dropped.
|
|
127
|
+
const NETWORK_ERROR = /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|EPIPE|UND_ERR_|socket hang up|other side closed/i;
|
|
128
|
+
|
|
129
|
+
export function isNetworkError(e) {
|
|
130
|
+
const text = [e?.message, e?.code, e?.cause?.code, e?.cause?.message].filter(Boolean).join(" ");
|
|
131
|
+
return NETWORK_ERROR.test(text);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function fetchWithRetry(url, init, { method = "GET", endpoint = "", fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), attempts } = {}) {
|
|
135
|
+
const max = attempts ?? (method === "GET" ? 3 : 1);
|
|
136
|
+
for (let i = 1; ; i++) {
|
|
137
|
+
try {
|
|
138
|
+
return await fetchImpl(url, init);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
if (!isNetworkError(e)) throw e;
|
|
141
|
+
const reason = e?.cause?.code || e?.cause?.message || e?.message || "network error";
|
|
142
|
+
if (i < max) {
|
|
143
|
+
await sleep(400 * 2 ** (i - 1) + Math.floor(Math.random() * 250));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const err = new Error(
|
|
147
|
+
method === "GET"
|
|
148
|
+
? `${method} ${endpoint} could not reach the API after ${max} tries (${reason}). ` +
|
|
149
|
+
`This is the network, not the request: run it again, and run fewer at once.`
|
|
150
|
+
: `${method} ${endpoint} lost its connection (${reason}). The server may still have finished the write: ` +
|
|
151
|
+
`check the result (the command's list / status / pull verb, or flowiq audit) before retrying.`
|
|
152
|
+
);
|
|
153
|
+
err.code = "ENETWORK";
|
|
154
|
+
err.cause = e;
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
121
160
|
async function call(method, endpoint, { query, body } = {}) {
|
|
122
161
|
const cfg = await loadConfig();
|
|
123
162
|
if (!cfg.token) {
|
|
@@ -148,7 +187,7 @@ async function call(method, endpoint, { query, body } = {}) {
|
|
|
148
187
|
init.body = JSON.stringify(body);
|
|
149
188
|
}
|
|
150
189
|
|
|
151
|
-
const resp = await
|
|
190
|
+
const resp = await fetchWithRetry(url, init, { method, endpoint });
|
|
152
191
|
noteServerContract(resp);
|
|
153
192
|
const text = await resp.text();
|
|
154
193
|
let parsed, isJson = true;
|