fix: cursor-plugin @latest tag, http retry, and login_url scheme validation#47
fix: cursor-plugin @latest tag, http retry, and login_url scheme validation#47ashish993 wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses three production-facing concerns in the CALL‑E integrations repo: keeping the Cursor plugin’s @latest git tag updated during releases, hardening broker-driven login URL handling, and improving resilience of core HTTP calls via retries.
Changes:
- Release workflow: initialize and update
@call-e/cursor-plugin@latesttag on publish. - Security: validate broker
login_urluseshttps:(except loopback) before opening in a browser. - Reliability: add exponential-backoff retries for transient HTTP status codes and network failures in
requestJson.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
.github/workflows/release.yml |
Adds Cursor plugin @latest tag initialization and update steps to match existing plugin workflows. |
packages/core/lib/http.js |
Implements retry loop with exponential backoff for transient HTTP and network failures. |
packages/core/lib/broker-client.js |
Adds login_url parsing + scheme validation with loopback exemption before returning the pending session. |
.changeset/fix-http-retry-and-login-url-validation.md |
Documents the patch-level release notes for the core changes. |
Comments suppressed due to low confidence (2)
packages/core/lib/http.js:80
- The retry catch-all will also retry non-network exceptions (e.g., JSON.parse SyntaxError or the "Expected JSON object" error thrown above). That can cause unexpected extra requests and makes invalid/malformed responses look like transient failures. Limit retries to actual fetch/network errors (e.g., TypeError from fetch / known errno codes), and let parse/validation errors fail fast.
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object") {
throw new Error(`Expected JSON object response for ${method} ${url}`);
}
return parsed;
} catch (error) {
if (error?.name === "AbortError") {
throw new Error(`Request timed out for ${method} ${url}`);
}
if (error instanceof HttpStatusError) {
throw error;
}
// Retry on network-level errors (ECONNRESET, ECONNREFUSED, etc.)
if (attempt < MAX_RETRY_ATTEMPTS) {
attempt++;
const delayMs = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
await sleep(delayMs);
continue;
}
packages/core/lib/http.js:55
- Retries are currently applied regardless of HTTP method. Since this helper is used with POST in broker-client, retrying after network failures / 5xx can duplicate non-idempotent operations (e.g., creating multiple sessions or exchanging twice). Consider restricting retries to idempotent methods (GET/HEAD) by default, or adding an explicit opt-in flag for retrying POST/other unsafe methods.
export async function requestJson(method, url, { headers = {}, json = undefined, timeoutSeconds = 15, fetchImpl = globalThis.fetch } = {}) {
if (typeof fetchImpl !== "function") {
throw new Error("global fetch is not available in this Node.js runtime");
}
let attempt = 0;
while (true) {
const controller = new AbortController();
const timeoutMs = Math.max(Math.ceil(Number(timeoutSeconds || 15) * 1000), 1000);
const timeout = setTimeout(() => controller.abort(), timeoutMs);
if (typeof timeout.unref === "function") {
timeout.unref();
}
try {
const response = await fetchImpl(url, {
method,
headers: {
Accept: "application/json",
...(json !== undefined ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: json !== undefined ? JSON.stringify(json) : undefined,
signal: controller.signal,
});
const text = await response.text();
if (!response.ok) {
const error = new HttpStatusError(`Client error '${response.status} ${response.statusText}' for url '${url}'`, {
statusCode: response.status,
responseText: text,
headers: Object.fromEntries(response.headers.entries()),
});
if (attempt < MAX_RETRY_ATTEMPTS && RETRYABLE_STATUS_CODES.has(response.status)) {
attempt++;
const delayMs = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
await sleep(delayMs);
continue;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const loginUrl = String(sessionPayload.login_url); | ||
| let parsedLoginUrl; | ||
| try { | ||
| parsedLoginUrl = new URL(loginUrl); | ||
| } catch { | ||
| throw new Error("Broker session returned an invalid login_url"); | ||
| } | ||
| const isLoopback = parsedLoginUrl.hostname === "localhost" || parsedLoginUrl.hostname === "127.0.0.1" || parsedLoginUrl.hostname === "::1"; | ||
| if (parsedLoginUrl.protocol !== "https:" && !isLoopback) { | ||
| throw new Error(`Broker session login_url must use https:, got '${parsedLoginUrl.protocol}'`); | ||
| } |
| const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]); | ||
| const MAX_RETRY_ATTEMPTS = 2; | ||
| const RETRY_BASE_DELAY_MS = 500; | ||
|
|
||
| async function sleep(ms) { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
|
|
||
| export async function requestJson(method, url, { headers = {}, json = undefined, timeoutSeconds = 15, fetchImpl = globalThis.fetch } = {}) { | ||
| if (typeof fetchImpl !== "function") { | ||
| throw new Error("global fetch is not available in this Node.js runtime"); | ||
| } | ||
|
|
||
| const controller = new AbortController(); | ||
| const timeoutMs = Math.max(Math.ceil(Number(timeoutSeconds || 15) * 1000), 1000); | ||
| const timeout = setTimeout(() => controller.abort(), timeoutMs); | ||
| if (typeof timeout.unref === "function") { | ||
| timeout.unref(); | ||
| } | ||
| let attempt = 0; | ||
| while (true) { | ||
| const controller = new AbortController(); | ||
| const timeoutMs = Math.max(Math.ceil(Number(timeoutSeconds || 15) * 1000), 1000); | ||
| const timeout = setTimeout(() => controller.abort(), timeoutMs); | ||
| if (typeof timeout.unref === "function") { | ||
| timeout.unref(); | ||
| } | ||
|
|
||
| try { | ||
| const response = await fetchImpl(url, { | ||
| method, | ||
| headers: { | ||
| Accept: "application/json", | ||
| ...(json !== undefined ? { "Content-Type": "application/json" } : {}), | ||
| ...headers, | ||
| }, | ||
| body: json !== undefined ? JSON.stringify(json) : undefined, | ||
| signal: controller.signal, | ||
| }); | ||
| const text = await response.text(); | ||
| if (!response.ok) { | ||
| throw new HttpStatusError(`Client error '${response.status} ${response.statusText}' for url '${url}'`, { | ||
| statusCode: response.status, | ||
| responseText: text, | ||
| headers: Object.fromEntries(response.headers.entries()), | ||
| try { | ||
| const response = await fetchImpl(url, { | ||
| method, | ||
| headers: { | ||
| Accept: "application/json", | ||
| ...(json !== undefined ? { "Content-Type": "application/json" } : {}), | ||
| ...headers, | ||
| }, | ||
| body: json !== undefined ? JSON.stringify(json) : undefined, | ||
| signal: controller.signal, | ||
| }); | ||
| const text = await response.text(); | ||
| if (!response.ok) { | ||
| const error = new HttpStatusError(`Client error '${response.status} ${response.statusText}' for url '${url}'`, { | ||
| statusCode: response.status, | ||
| responseText: text, | ||
| headers: Object.fromEntries(response.headers.entries()), | ||
| }); | ||
| if (attempt < MAX_RETRY_ATTEMPTS && RETRYABLE_STATUS_CODES.has(response.status)) { | ||
| attempt++; | ||
| const delayMs = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); | ||
| await sleep(delayMs); | ||
| continue; | ||
| } | ||
| throw error; | ||
| } | ||
| if (!text.trim()) { | ||
| return {}; | ||
| } | ||
| const parsed = JSON.parse(text); | ||
| if (!parsed || typeof parsed !== "object") { | ||
| throw new Error(`Expected JSON object response for ${method} ${url}`); | ||
| } | ||
| return parsed; | ||
| } catch (error) { | ||
| if (error?.name === "AbortError") { | ||
| throw new Error(`Request timed out for ${method} ${url}`); | ||
| } | ||
| if (error instanceof HttpStatusError) { | ||
| throw error; | ||
| } | ||
| // Retry on network-level errors (ECONNRESET, ECONNREFUSED, etc.) | ||
| if (attempt < MAX_RETRY_ATTEMPTS) { | ||
| attempt++; | ||
| const delayMs = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); | ||
| await sleep(delayMs); | ||
| continue; | ||
| } |
…dation - Add Initialize Cursor latest ref and Update Cursor latest ref steps to the release workflow, matching the existing Codex and Claude plugin steps. The cursor-plugin @latest tag was never initialized or updated on publish. - Add exponential backoff retry in requestJson for transient HTTP errors (429, 500, 502, 503, 504) and network-level failures. Retries up to 2 times with 500ms / 1000ms delays before re-throwing. - Validate that the broker server's login_url uses https: before passing it to openBrowser / spawn. Loopback addresses (localhost, 127.0.0.1, ::1) are exempt to preserve local development and test compatibility.
7746aa5 to
00cf0a1
Compare
There was a problem hiding this comment.
Thanks for the PR. I would hold off on merging this until the retry behavior is tightened up. I found a few issues:
-
requestJsonnow retries every non-HttpStatusErrorthrown afterfetch, including local response parsing/validation failures. For example, a 200 response with malformed JSON is retried twice before throwing; I reproduced this with an invalid JSON response and saw 3 total requests. Those are not transient network failures, so malformed/invalid responses should fail fast. -
Retries apply to every HTTP method. This helper is used by broker
POST /sessionsandPOST /exchange, so a transient 5xx or network failure can duplicate non-idempotent operations. I would restrict retries to idempotent methods by default, or make POST retries an explicit opt-in where the caller knows it is safe. -
The IPv6 loopback exemption does not currently work as described. In Node,
new URL("http://[::1]/").hostnamereturns[::1], not::1, sohttp://[::1]/...is rejected even though the PR says loopback addresses are exempt.
Validation I ran locally on a temporary worktree:
pnpm testpnpm run check:versionspnpm -r --filter "./packages/*" run check
Those passed. I also checked the PR branch with gh pr checks 47, and GitHub currently reports no checks for this PR branch.
Changes
Bug fix – release workflow
Initialize Cursor latest refandUpdate Cursor latest refsteps to.github/workflows/release.yml. The@call-e/cursor-plugin@latestgit tag was never initialized or updated on publish, unlike the Codex and Claude plugin equivalents.Security – broker login_url validation (
packages/core/lib/broker-client.js)login_urlreturned by the broker server useshttps:before it is passed toopenBrowser/spawn. Loopback addresses are exempt to preserve local-dev and test compatibility.Reliability – HTTP retry with exponential backoff (
packages/core/lib/http.js)Testing
All 40 existing unit and e2e tests pass (
pnpm test && pnpm check).