@floomhq/floom 1.0.31 → 1.0.33

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/dist/errors.js CHANGED
@@ -28,6 +28,9 @@ export function friendlyHttp(status, action) {
28
28
  if (status === 413) {
29
29
  return new FloomError("That file is too large to publish.");
30
30
  }
31
+ if (status === 429) {
32
+ return new FloomError("Floom is rate limiting requests.", "The CLI retries rate-limited requests automatically. Try again in a moment if this persists.");
33
+ }
31
34
  if (status >= 500) {
32
35
  return new FloomError("Floom is having trouble right now.", "Try again in a moment.");
33
36
  }
package/dist/lib/api.js CHANGED
@@ -4,37 +4,46 @@ import { friendlyHttp, friendlyNetwork, FloomError } from "../errors.js";
4
4
  * patterns but throws FloomError instances so the CLI's printError gives
5
5
  * users a clean message instead of a stack trace.
6
6
  */
7
- export const DEFAULT_TIMEOUT_MS = 15_000;
7
+ export const DEFAULT_TIMEOUT_MS = 60_000;
8
8
  export async function floomFetch(url, action, opts = {}) {
9
9
  const headers = { ...(opts.headers ?? {}) };
10
10
  if (opts.token)
11
11
  headers.authorization = `Bearer ${opts.token}`;
12
12
  if (opts.body !== undefined)
13
13
  headers["content-type"] = "application/json";
14
- const controller = new AbortController();
15
- const timer = setTimeout(() => controller.abort(), requestTimeoutMs(opts.timeoutMs));
16
- let res;
17
- try {
18
- res = await fetch(url, {
19
- method: opts.method ?? "GET",
20
- headers,
21
- signal: controller.signal,
22
- ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
23
- });
24
- }
25
- catch (err) {
26
- if (err.name === "AbortError") {
27
- throw new FloomError(`Request timed out while trying to ${action}.`, "Try again in a moment.");
14
+ const attempts = Math.max(1, 1 + rateLimitRetries(opts.rateLimitRetries));
15
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
16
+ const controller = new AbortController();
17
+ const timer = setTimeout(() => controller.abort(), requestTimeoutMs(opts.timeoutMs));
18
+ let res;
19
+ try {
20
+ res = await fetch(url, {
21
+ method: opts.method ?? "GET",
22
+ headers,
23
+ signal: controller.signal,
24
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
25
+ });
28
26
  }
29
- throw friendlyNetwork(err);
30
- }
31
- finally {
32
- clearTimeout(timer);
33
- }
34
- if (opts.checkOk !== false && !res.ok) {
35
- throw friendlyHttp(res.status, action);
27
+ catch (err) {
28
+ if (err.name === "AbortError") {
29
+ throw new FloomError(`Request timed out while trying to ${action}.`, "Try again in a moment.");
30
+ }
31
+ throw friendlyNetwork(err);
32
+ }
33
+ finally {
34
+ clearTimeout(timer);
35
+ }
36
+ if (res.status === 429 && attempt < attempts - 1) {
37
+ await drainResponse(res);
38
+ await sleep(rateLimitDelayMs(res, attempt));
39
+ continue;
40
+ }
41
+ if (opts.checkOk !== false && !res.ok) {
42
+ throw friendlyHttp(res.status, action);
43
+ }
44
+ return res;
36
45
  }
37
- return res;
46
+ throw new FloomError(`Request failed (HTTP 429) while trying to ${action}.`);
38
47
  }
39
48
  function requestTimeoutMs(override) {
40
49
  if (override !== undefined)
@@ -44,6 +53,50 @@ function requestTimeoutMs(override) {
44
53
  return fromEnv;
45
54
  return DEFAULT_TIMEOUT_MS;
46
55
  }
56
+ function rateLimitRetries(override) {
57
+ if (override !== undefined)
58
+ return Math.max(0, Math.floor(override));
59
+ const fromEnv = Number(process.env.FLOOM_RATE_LIMIT_RETRIES);
60
+ if (Number.isFinite(fromEnv) && fromEnv >= 0)
61
+ return Math.floor(fromEnv);
62
+ return 8;
63
+ }
64
+ function rateLimitDelayMs(res, attempt) {
65
+ const retryAfter = res.headers.get("retry-after");
66
+ if (retryAfter) {
67
+ const seconds = Number(retryAfter);
68
+ if (Number.isFinite(seconds) && seconds >= 0) {
69
+ return capRateLimitDelay(seconds * 1000);
70
+ }
71
+ const dateMs = Date.parse(retryAfter);
72
+ if (Number.isFinite(dateMs)) {
73
+ return capRateLimitDelay(Math.max(0, dateMs - Date.now()));
74
+ }
75
+ }
76
+ const resetSeconds = Number(res.headers.get("x-ratelimit-reset"));
77
+ if (Number.isFinite(resetSeconds) && resetSeconds > 0) {
78
+ return capRateLimitDelay(Math.max(0, (resetSeconds * 1000) - Date.now()));
79
+ }
80
+ const base = Number(process.env.FLOOM_RATE_LIMIT_RETRY_MS);
81
+ const fallback = Number.isFinite(base) && base >= 0 ? base : 1_000;
82
+ return capRateLimitDelay(fallback * (attempt + 1));
83
+ }
84
+ function capRateLimitDelay(ms) {
85
+ const fromEnv = Number(process.env.FLOOM_RATE_LIMIT_MAX_WAIT_MS);
86
+ const max = Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 65_000;
87
+ return Math.min(Math.max(0, ms), max);
88
+ }
89
+ async function drainResponse(res) {
90
+ try {
91
+ await res.arrayBuffer();
92
+ }
93
+ catch {
94
+ // Ignore bodies from rate-limit responses; the retry decision is header-based.
95
+ }
96
+ }
97
+ function sleep(ms) {
98
+ return new Promise((resolve) => setTimeout(resolve, ms));
99
+ }
47
100
  export async function getJson(url, action, token) {
48
101
  const res = await floomFetch(url, action, token ? { token } : {});
49
102
  return (await res.json());
@@ -5,7 +5,7 @@ import { CONFIG_DIR } from "./config.js";
5
5
  const MANIFEST_VERSION = 1;
6
6
  const MANIFEST_PATH = join(CONFIG_DIR, "sync-manifest.json");
7
7
  const LOCK_PATH = join(CONFIG_DIR, "sync.lock");
8
- const LOCK_TIMEOUT_MS = 15_000;
8
+ const LOCK_TIMEOUT_MS = 60_000;
9
9
  const LOCK_STALE_MS = 5 * 60_000;
10
10
  const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
11
11
  const FD_PATH_ROOT = "/proc/self/fd";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom",
3
- "version": "1.0.31",
3
+ "version": "1.0.33",
4
4
  "description": "Sync AI skills across agents and machines.",
5
5
  "license": "MIT",
6
6
  "type": "module",