From 7a6835f05eb2f2978e5fbfc06e3dccf737b77eca Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:25:21 +0530 Subject: [PATCH 01/15] feat: add retry utility with exponential backoff and jitter --- packages/core-internal/src/shared/retry.ts | 180 +++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 packages/core-internal/src/shared/retry.ts diff --git a/packages/core-internal/src/shared/retry.ts b/packages/core-internal/src/shared/retry.ts new file mode 100644 index 0000000..6cd9a15 --- /dev/null +++ b/packages/core-internal/src/shared/retry.ts @@ -0,0 +1,180 @@ +/** + * Retry utility with exponential backoff and jitter. + * + * Provides transport-agnostic retry logic for async operations. + * Used by fetch-timeout.ts and pkgseer-graphql.ts to add resilience + * against transient network failures. + * + * Scope boundary: + * - Owns: retry loop, backoff calculation, jitter, error classification + * - Does NOT own: HTTP logic, token refresh, MCP-specific behavior + */ + +import { FetchTimeoutError } from "./fetch-timeout.js"; + +/** + * Options for retry behavior. + */ +export interface RetryOptions { + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Base delay in milliseconds for exponential backoff (default: 1000) */ + baseDelayMs?: number; + /** Maximum delay in milliseconds (default: 30000) */ + maxDelayMs?: number; + /** Whether to add jitter to delay (default: true) */ + jitter?: boolean; + /** Custom function to determine if error is retryable (default: isRetryableError) */ + retryOn?: (error: unknown) => boolean; + /** Callback invoked before each retry */ + onRetry?: (attempt: number, error: unknown, delayMs: number) => void; +} + +/** + * Determines if an error is retryable based on error type and properties. + * + * Retryable errors: + * - FetchTimeoutError (network timeout) + * - PkgseerTransportError (network failure before response) + * - HTTP 429 (rate limited) + * - HTTP 5xx (server error) + * + * Non-retryable errors: + * - HTTP 4xx (client error, except 429) + * - AuthenticationError (handled by token refresh) + * - Unknown errors + */ +export function isRetryableError(error: unknown): boolean { + // Network timeout is retryable + if (error instanceof FetchTimeoutError) { + return true; + } + + // PkgseerTransportError (network failure before response) is retryable + if (error instanceof Error && error.name === "PkgseerTransportError") { + return true; + } + + // Check for HTTP status-based errors + if (error && typeof error === "object" && "status" in error) { + const status = (error as { status: unknown }).status; + if (typeof status === "number") { + // Rate limiting (429) is retryable + if (status === 429) { + return true; + } + // Server errors (5xx) are retryable + if (status >= 500 && status < 600) { + return true; + } + // Client errors (4xx except 429) are NOT retryable + if (status >= 400 && status < 500) { + return false; + } + } + } + + // Check for error with retryable flag (from backend extensions) + if ( + error && + typeof error === "object" && + "retryable" in error && + (error as { retryable: unknown }).retryable === true + ) { + return true; + } + + // Default: not retryable + return false; +} + +/** + * Calculates delay with exponential backoff and optional jitter. + * + * @param attempt - Current attempt number (0-based) + * @param baseDelayMs - Base delay in milliseconds + * @param maxDelayMs - Maximum delay cap + * @param jitter - Whether to add randomness + * @returns Delay in milliseconds + */ +export function calculateDelay( + attempt: number, + baseDelayMs: number, + maxDelayMs: number, + jitter: boolean, +): number { + // Exponential backoff: baseDelay * 2^attempt + let delay = baseDelayMs * 2 ** attempt; + + // Add jitter if enabled (full jitter: delay * random(0.5, 1.0)) + if (jitter) { + delay = delay * (0.5 + Math.random() * 0.5); + } + + // Cap at maxDelayMs + return Math.min(delay, maxDelayMs); +} + +/** + * Executes an async function with retry logic and exponential backoff. + * + * @param fn - Async function to execute + * @param options - Retry configuration options + * @returns Promise resolving to the function's result + * @throws Last error if all retries fail + * + * @example + * ```typescript + * const response = await retryWithBackoff( + * () => fetch("https://api.example.com/data"), + * { maxRetries: 3, baseDelayMs: 1000 } + * ); + * ``` + */ +export async function retryWithBackoff( + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + maxRetries = 3, + baseDelayMs = 1000, + maxDelayMs = 30_000, + jitter = true, + retryOn = isRetryableError, + onRetry, + } = options; + + let lastError: unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + // Check if we should retry + if (!retryOn(error)) { + throw error; + } + + // Check if we have retries left + if (attempt >= maxRetries) { + throw error; + } + + // Calculate delay for next attempt + const delay = calculateDelay(attempt, baseDelayMs, maxDelayMs, jitter); + + // Invoke callback if provided + if (onRetry) { + onRetry(attempt + 1, error, delay); + } + + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + // This should never be reached, but TypeScript needs it + throw lastError; +} From e10a90aa52e05708fd5e83e2d273a45b5623f011 Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:26:42 +0530 Subject: [PATCH 02/15] test: add comprehensive tests for retry utility --- .../core-internal/src/shared/retry.test.ts | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 packages/core-internal/src/shared/retry.test.ts diff --git a/packages/core-internal/src/shared/retry.test.ts b/packages/core-internal/src/shared/retry.test.ts new file mode 100644 index 0000000..ed0dd96 --- /dev/null +++ b/packages/core-internal/src/shared/retry.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { FetchTimeoutError, isFetchTimeoutError } from "./fetch-timeout.js"; +import { + calculateDelay, + isRetryableError, + type RetryOptions, + retryWithBackoff, +} from "./retry.js"; + +// Mock PkgseerTransportError for testing +class MockPkgseerTransportError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "PkgseerTransportError"; + } +} + +// Mock error with status +class MockHttpError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message); + this.name = "HttpError"; + } +} + +// Mock error with retryable flag +class MockRetryableError extends Error { + constructor(message: string) { + super(message); + this.name = "RetryableError"; + (this as unknown as { retryable: boolean }).retryable = true; + } +} + +describe("isRetryableError", () => { + it("returns true for FetchTimeoutError", () => { + const error = new FetchTimeoutError(1000); + expect(isRetryableError(error)).toBe(true); + }); + + it("returns true for PkgseerTransportError", () => { + const error = new MockPkgseerTransportError("Network failed"); + expect(isRetryableError(error)).toBe(true); + }); + + it("returns true for HTTP 429 (rate limited)", () => { + const error = new MockHttpError("Rate limited", 429); + expect(isRetryableError(error)).toBe(true); + }); + + it("returns true for HTTP 500 (server error)", () => { + const error = new MockHttpError("Server error", 500); + expect(isRetryableError(error)).toBe(true); + }); + + it("returns true for HTTP 503 (service unavailable)", () => { + const error = new MockHttpError("Service unavailable", 503); + expect(isRetryableError(error)).toBe(true); + }); + + it("returns false for HTTP 400 (bad request)", () => { + const error = new MockHttpError("Bad request", 400); + expect(isRetryableError(error)).toBe(false); + }); + + it("returns false for HTTP 401 (unauthorized)", () => { + const error = new MockHttpError("Unauthorized", 401); + expect(isRetryableError(error)).toBe(false); + }); + + it("returns false for HTTP 404 (not found)", () => { + const error = new MockHttpError("Not found", 404); + expect(isRetryableError(error)).toBe(false); + }); + + it("returns true for error with retryable flag", () => { + const error = new MockRetryableError("Retryable"); + expect(isRetryableError(error)).toBe(true); + }); + + it("returns false for unknown errors", () => { + const error = new Error("Unknown error"); + expect(isRetryableError(error)).toBe(false); + }); + + it("returns false for non-Error values", () => { + expect(isRetryableError("string error")).toBe(false); + expect(isRetryableError(42)).toBe(false); + expect(isRetryableError(null)).toBe(false); + expect(isRetryableError(undefined)).toBe(false); + }); +}); + +describe("calculateDelay", () => { + it("calculates exponential backoff without jitter", () => { + const baseDelayMs = 1000; + const maxDelayMs = 30000; + const jitter = false; + + // attempt 0: 1000 * 2^0 = 1000 + expect(calculateDelay(0, baseDelayMs, maxDelayMs, jitter)).toBe(1000); + // attempt 1: 1000 * 2^1 = 2000 + expect(calculateDelay(1, baseDelayMs, maxDelayMs, jitter)).toBe(2000); + // attempt 2: 1000 * 2^2 = 4000 + expect(calculateDelay(2, baseDelayMs, maxDelayMs, jitter)).toBe(4000); + }); + + it("caps delay at maxDelayMs", () => { + const baseDelayMs = 1000; + const maxDelayMs = 5000; + const jitter = false; + + // attempt 10: 1000 * 2^10 = 1024000, but capped at 5000 + expect(calculateDelay(10, baseDelayMs, maxDelayMs, jitter)).toBe(5000); + }); + + it("adds jitter when enabled", () => { + const baseDelayMs = 1000; + const maxDelayMs = 30000; + const jitter = true; + + // With jitter, delay should be between 500 and 1000 for attempt 0 + // We can't test exact values due to randomness, but we can test range + const delays = Array.from({ length: 100 }, () => + calculateDelay(0, baseDelayMs, maxDelayMs, jitter), + ); + + const minDelay = Math.min(...delays); + const maxDelay = Math.max(...delays); + + // All delays should be between 500 (50% of 1000) and 1000 (100% of 1000) + expect(minDelay).toBeGreaterThanOrEqual(500); + expect(maxDelay).toBeLessThanOrEqual(1000); + }); +}); + +describe("retryWithBackoff", () => { + beforeEach(() => { + // Mock setTimeout to avoid actual delays in tests + globalThis.setTimeout = mock((_fn: () => void, _ms: number) => 0) as never; + }); + + afterEach(() => { + // Restore original setTimeout + globalThis.setTimeout = originalSetTimeout; + }); + + it("succeeds on first attempt without retry", async () => { + const fn = mock(() => Promise.resolve("success")); + + const result = await retryWithBackoff(fn, { maxRetries: 3 }); + + expect(result).toBe("success"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("retries on retryable error and succeeds", async () => { + let callCount = 0; + const fn = mock(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new FetchTimeoutError(1000)); + } + return Promise.resolve("success after retry"); + }); + + const result = await retryWithBackoff(fn, { + maxRetries: 3, + baseDelayMs: 100, // Use small delay for tests + }); + + expect(result).toBe("success after retry"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("fails after maxRetries exceeded", async () => { + const error = new FetchTimeoutError(1000); + const fn = mock(() => Promise.reject(error)); + + await expect( + retryWithBackoff(fn, { + maxRetries: 2, + baseDelayMs: 100, + }), + ).rejects.toThrow(error); + + // Initial attempt + 2 retries = 3 calls total + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("does not retry non-retryable errors", async () => { + const error = new MockHttpError("Bad request", 400); + const fn = mock(() => Promise.reject(error)); + + await expect( + retryWithBackoff(fn, { + maxRetries: 3, + baseDelayMs: 100, + }), + ).rejects.toThrow(error); + + // Only initial attempt, no retries + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("calls onRetry hook with correct parameters", async () => { + let callCount = 0; + const fn = mock(() => { + callCount++; + if (callCount <= 2) { + return Promise.reject(new FetchTimeoutError(1000)); + } + return Promise.resolve("success"); + }); + + const onRetry = mock(() => {}); + + const result = await retryWithBackoff(fn, { + maxRetries: 3, + baseDelayMs: 100, + onRetry, + }); + + expect(result).toBe("success"); + expect(onRetry).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledWith( + 1, + expect.any(FetchTimeoutError), + expect.any(Number), + ); + expect(onRetry).toHaveBeenCalledWith( + 2, + expect.any(FetchTimeoutError), + expect.any(Number), + ); + }); + + it("uses custom retryOn function", async () => { + const error = new MockHttpError("Server error", 500); + const fn = mock(() => Promise.reject(error)); + + // Custom retryOn that never retries + const retryOn = mock(() => false); + + await expect( + retryWithBackoff(fn, { + maxRetries: 3, + baseDelayMs: 100, + retryOn, + }), + ).rejects.toThrow(error); + + expect(retryOn).toHaveBeenCalledWith(error); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("respects zero maxRetries (no retries)", async () => { + const error = new FetchTimeoutError(1000); + const fn = mock(() => Promise.reject(error)); + + await expect( + retryWithBackoff(fn, { + maxRetries: 0, + baseDelayMs: 100, + }), + ).rejects.toThrow(error); + + // Only initial attempt + expect(fn).toHaveBeenCalledTimes(1); + }); +}); + +// Store original setTimeout +const originalSetTimeout = globalThis.setTimeout; From 03062a96a6a5dda53b3a5c50b3a16e649a3dd0e8 Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:27:35 +0530 Subject: [PATCH 03/15] feat: add retryFetchWithTimeout with exponential backoff --- .../core-internal/src/shared/fetch-timeout.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/core-internal/src/shared/fetch-timeout.ts b/packages/core-internal/src/shared/fetch-timeout.ts index 207d443..0ee715b 100644 --- a/packages/core-internal/src/shared/fetch-timeout.ts +++ b/packages/core-internal/src/shared/fetch-timeout.ts @@ -1,3 +1,9 @@ +import { + isRetryableError, + type RetryOptions, + retryWithBackoff, +} from "./retry.js"; + export const DEFAULT_FETCH_TIMEOUT_MS = 120_000; export class FetchTimeoutError extends Error { @@ -15,6 +21,26 @@ export interface FetchWithTimeoutOptions { timeoutMs?: number; } +/** + * Options for retry-enabled fetch with timeout. + */ +export interface RetryFetchOptions extends FetchWithTimeoutOptions { + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Base delay in milliseconds for exponential backoff (default: 1000) */ + baseDelayMs?: number; + /** Maximum delay in milliseconds (default: 30000) */ + maxDelayMs?: number; + /** Whether to add jitter to delay (default: true) */ + jitter?: boolean; + /** Whether the request is idempotent (safe to retry, default: true) */ + idempotent?: boolean; + /** Custom function to determine if error is retryable */ + retryOn?: (error: unknown) => boolean; + /** Callback invoked before each retry */ + onRetry?: (attempt: number, error: unknown, delayMs: number) => void; +} + export async function fetchWithTimeout( input: Parameters[0], init: RequestInit = {}, @@ -51,3 +77,55 @@ export function isFetchTimeoutError( ): error is FetchTimeoutError { return error instanceof FetchTimeoutError; } + +/** + * Executes a fetch request with timeout and retry logic. + * + * Wraps fetchWithTimeout with exponential backoff retry for transient failures. + * Only retries if the request is idempotent (default: true). + * + * @param input - URL or Request object + * @param init - Request options + * @param options - Fetch and retry configuration + * @returns Promise resolving to the Response + * @throws Last error if all retries fail or if error is non-retryable + * + * @example + * ```typescript + * const response = await retryFetchWithTimeout( + * "https://api.example.com/data", + * { method: "POST", body: JSON.stringify({ query: "test" }) }, + * { maxRetries: 3, baseDelayMs: 1000 } + * ); + * ``` + */ +export async function retryFetchWithTimeout( + input: Parameters[0], + init: RequestInit = {}, + options: RetryFetchOptions = {}, +): Promise { + const { + maxRetries = 3, + baseDelayMs = 1000, + maxDelayMs = 30_000, + jitter = true, + idempotent = true, + retryOn = isRetryableError, + onRetry, + ...fetchOptions + } = options; + + // If not idempotent, don't retry + if (!idempotent) { + return fetchWithTimeout(input, init, fetchOptions); + } + + return retryWithBackoff(() => fetchWithTimeout(input, init, fetchOptions), { + maxRetries, + baseDelayMs, + maxDelayMs, + jitter, + retryOn, + onRetry, + }); +} From 16a636895b7cbe04359fd316b50891341bf1c71a Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:29:18 +0530 Subject: [PATCH 04/15] test: add tests for retryFetchWithTimeout --- .../src/shared/fetch-timeout.test.ts | 134 +++++++++++++++++- 1 file changed, 132 insertions(+), 2 deletions(-) diff --git a/packages/core-internal/src/shared/fetch-timeout.test.ts b/packages/core-internal/src/shared/fetch-timeout.test.ts index c6ccf47..13a14d1 100644 --- a/packages/core-internal/src/shared/fetch-timeout.test.ts +++ b/packages/core-internal/src/shared/fetch-timeout.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it, mock } from "bun:test"; -import { FetchTimeoutError, fetchWithTimeout } from "./fetch-timeout.js"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { + FetchTimeoutError, + fetchWithTimeout, + retryFetchWithTimeout, +} from "./fetch-timeout.js"; function asFetchFn unknown>( fn: T, @@ -7,6 +11,9 @@ function asFetchFn unknown>( return fn as unknown as typeof fetch; } +// Store original setTimeout +const originalSetTimeout = globalThis.setTimeout; + describe("fetchWithTimeout", () => { it("passes a timeout signal to fetch", async () => { const fetchFn = mock((_url: string, init?: RequestInit) => { @@ -59,3 +66,126 @@ describe("fetchWithTimeout", () => { ).rejects.toBe(cause); }); }); + +describe("retryFetchWithTimeout", () => { + beforeEach(() => { + // Mock setTimeout to avoid actual delays in tests + globalThis.setTimeout = mock((_fn: () => void, _ms: number) => 0) as never; + }); + + afterEach(() => { + // Restore original setTimeout + globalThis.setTimeout = originalSetTimeout; + }); + + it("succeeds on first attempt without retry", async () => { + const fetchFn = mock(() => Promise.resolve(new Response("ok"))); + + const response = await retryFetchWithTimeout( + "https://example.com", + {}, + { + fetchFn: asFetchFn(fetchFn), + timeoutMs: 100, + maxRetries: 3, + }, + ); + + expect(await response.text()).toBe("ok"); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("retries on FetchTimeoutError and succeeds", async () => { + let callCount = 0; + const fetchFn = mock(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new FetchTimeoutError(100)); + } + return Promise.resolve(new Response("ok after retry")); + }); + + const response = await retryFetchWithTimeout( + "https://example.com", + {}, + { + fetchFn: asFetchFn(fetchFn), + timeoutMs: 100, + maxRetries: 3, + baseDelayMs: 100, + }, + ); + + expect(await response.text()).toBe("ok after retry"); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it("does not retry when idempotent=false", async () => { + const error = new FetchTimeoutError(100); + const fetchFn = mock(() => Promise.reject(error)); + + await expect( + retryFetchWithTimeout( + "https://example.com", + {}, + { + fetchFn: asFetchFn(fetchFn), + timeoutMs: 100, + idempotent: false, + }, + ), + ).rejects.toThrow(error); + + // Only initial attempt, no retries + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("respects maxRetries option", async () => { + const error = new FetchTimeoutError(100); + const fetchFn = mock(() => Promise.reject(error)); + + await expect( + retryFetchWithTimeout( + "https://example.com", + {}, + { + fetchFn: asFetchFn(fetchFn), + timeoutMs: 100, + maxRetries: 2, + baseDelayMs: 100, + }, + ), + ).rejects.toThrow(error); + + // Initial attempt + 2 retries = 3 calls total + expect(fetchFn).toHaveBeenCalledTimes(3); + }); + + it("calls onRetry hook", async () => { + let callCount = 0; + const fetchFn = mock(() => { + callCount++; + if (callCount <= 2) { + return Promise.reject(new FetchTimeoutError(100)); + } + return Promise.resolve(new Response("ok")); + }); + + const onRetry = mock(() => {}); + + const response = await retryFetchWithTimeout( + "https://example.com", + {}, + { + fetchFn: asFetchFn(fetchFn), + timeoutMs: 100, + maxRetries: 3, + baseDelayMs: 100, + onRetry, + }, + ); + + expect(await response.text()).toBe("ok"); + expect(onRetry).toHaveBeenCalledTimes(2); + }); +}); From f2959dd3c0bc07611bca48ccc3efe1321efcf8ec Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:31:35 +0530 Subject: [PATCH 05/15] feat: add retry support to GraphQL transport --- .../src/shared/pkgseer-graphql.ts | 124 ++++++++++++++---- 1 file changed, 96 insertions(+), 28 deletions(-) diff --git a/packages/core-internal/src/shared/pkgseer-graphql.ts b/packages/core-internal/src/shared/pkgseer-graphql.ts index c816064..b023f38 100644 --- a/packages/core-internal/src/shared/pkgseer-graphql.ts +++ b/packages/core-internal/src/shared/pkgseer-graphql.ts @@ -24,7 +24,12 @@ */ import { debugLog } from "./debug-log.js"; -import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithTimeout } from "./fetch-timeout.js"; +import { + DEFAULT_FETCH_TIMEOUT_MS, + fetchWithTimeout, + type RetryFetchOptions, + retryFetchWithTimeout, +} from "./fetch-timeout.js"; import type { ClientHeaderBuilder } from "./request-headers.js"; export interface PkgseerGraphqlRequest { @@ -44,6 +49,17 @@ export interface PkgseerGraphqlRequest { userAgent?: string; /** Optional per-runtime GitHits telemetry headers. */ clientHeaders?: ClientHeaderBuilder; + /** Retry configuration for transient failures */ + retryOptions?: { + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Base delay in milliseconds for exponential backoff (default: 1000) */ + baseDelayMs?: number; + /** Maximum delay in milliseconds (default: 30000) */ + maxDelayMs?: number; + /** Whether to add jitter to delay (default: true) */ + jitter?: boolean; + }; } export interface PkgseerGraphqlResponse { @@ -83,42 +99,94 @@ function baseUrl(endpointUrl: string): string { /** * One authenticated POST to the pkgseer GraphQL endpoint. See module * comment for the scope boundary. + * + * When retryOptions are provided, retries on transient network failures + * with exponential backoff. GraphQL POST is idempotent (same query = same result). */ export async function postPkgseerGraphql( request: PkgseerGraphqlRequest, ): Promise { const userAgent = request.userAgent ?? "githits-cli"; const timeoutMs = request.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; + const { retryOptions, ...requestWithoutRetry } = request; + + const fetchFn = async (): Promise => { + let response: Response; + try { + response = await fetchWithTimeout( + `${baseUrl(request.endpointUrl)}/api/graphql`, + { + method: "POST", + headers: { + ...request.clientHeaders?.(), + Authorization: `Bearer ${request.token}`, + "Content-Type": "application/json", + "User-Agent": userAgent, + }, + body: JSON.stringify({ + query: request.query, + variables: request.variables, + }), + }, + { fetchFn: request.fetchFn, timeoutMs }, + ); + } catch (cause) { + debugLog("pkg-graphql", { + event: "transport-error", + errorName: cause instanceof Error ? cause.name : typeof cause, + hasCause: true, + }); + throw new PkgseerTransportError( + "Network request failed before a response was received. Caller should re-wrap with a domain-specific message.", + { cause }, + ); + } + + return response; + }; let response: Response; - try { - response = await fetchWithTimeout( - `${baseUrl(request.endpointUrl)}/api/graphql`, - { - method: "POST", - headers: { - ...request.clientHeaders?.(), - Authorization: `Bearer ${request.token}`, - "Content-Type": "application/json", - "User-Agent": userAgent, + if (retryOptions) { + // Use retry logic for transient failures + const retryFetchFn = async (): Promise => { + return retryFetchWithTimeout( + `${baseUrl(request.endpointUrl)}/api/graphql`, + { + method: "POST", + headers: { + ...request.clientHeaders?.(), + Authorization: `Bearer ${request.token}`, + "Content-Type": "application/json", + "User-Agent": userAgent, + }, + body: JSON.stringify({ + query: request.query, + variables: request.variables, + }), + }, + { + fetchFn: request.fetchFn, + timeoutMs, + maxRetries: retryOptions.maxRetries, + baseDelayMs: retryOptions.baseDelayMs, + maxDelayMs: retryOptions.maxDelayMs, + jitter: retryOptions.jitter, + idempotent: true, // GraphQL POST is idempotent + onRetry: (attempt, error, delayMs) => { + debugLog("pkg-graphql", { + event: "retry", + attempt, + delayMs, + errorName: error instanceof Error ? error.name : typeof error, + }); + }, }, - body: JSON.stringify({ - query: request.query, - variables: request.variables, - }), - }, - { fetchFn: request.fetchFn, timeoutMs }, - ); - } catch (cause) { - debugLog("pkg-graphql", { - event: "transport-error", - errorName: cause instanceof Error ? cause.name : typeof cause, - hasCause: true, - }); - throw new PkgseerTransportError( - "Network request failed before a response was received. Caller should re-wrap with a domain-specific message.", - { cause }, - ); + ); + }; + response = await retryFetchFn(); + } else { + // Use single attempt (backward compatible) + response = await fetchFn(); } const responseBody = await response.text().catch(() => ""); From c86181c8c203fe0a3224f00f69e295721e49b008 Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:33:23 +0530 Subject: [PATCH 06/15] test: add retry tests for GraphQL transport --- .../src/shared/pkgseer-graphql.test.ts | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/packages/core-internal/src/shared/pkgseer-graphql.test.ts b/packages/core-internal/src/shared/pkgseer-graphql.test.ts index cefe98f..c119038 100644 --- a/packages/core-internal/src/shared/pkgseer-graphql.test.ts +++ b/packages/core-internal/src/shared/pkgseer-graphql.test.ts @@ -6,6 +6,9 @@ import { } from "./pkgseer-graphql.js"; import { createClientHeaderBuilder } from "./request-headers.js"; +// Store original setTimeout +const originalSetTimeout = globalThis.setTimeout; + function makeResponse( body: string, init?: { status?: number; headers?: Record }, @@ -316,4 +319,150 @@ describe("postPkgseerGraphql", () => { expect(combined).not.toContain(TOKEN); expect(combined).not.toContain("query { x }"); }); + + describe("retry support", () => { + beforeEach(() => { + // Mock setTimeout to avoid actual delays in tests + globalThis.setTimeout = mock( + (_fn: () => void, _ms: number) => 0, + ) as never; + }); + + afterEach(() => { + // Restore original setTimeout + globalThis.setTimeout = originalSetTimeout; + }); + + it("does not retry when retryOptions not provided", async () => { + const fetchFn = mock(() => Promise.reject(new Error("Network error"))); + + await expect( + postPkgseerGraphql({ + endpointUrl: ENDPOINT, + token: TOKEN, + query: "query { x }", + variables: {}, + fetchFn: asFetchFn(fetchFn), + }), + ).rejects.toThrow(PkgseerTransportError); + + // Only one attempt + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("retries on network failure when retryOptions provided", async () => { + let callCount = 0; + const fetchFn = mock(() => { + callCount++; + if (callCount <= 2) { + return Promise.reject(new Error("Network error")); + } + return Promise.resolve(makeResponse(VALID_JSON)); + }); + + const result = await postPkgseerGraphql({ + endpointUrl: ENDPOINT, + token: TOKEN, + query: "query { x }", + variables: {}, + fetchFn: asFetchFn(fetchFn), + retryOptions: { + maxRetries: 3, + baseDelayMs: 100, + }, + }); + + expect(result.status).toBe(200); + expect(fetchFn).toHaveBeenCalledTimes(3); // 2 failures + 1 success + }); + + it("retries on timeout when retryOptions provided", async () => { + let callCount = 0; + const fetchFn = mock(() => { + callCount++; + if (callCount <= 1) { + return Promise.reject(new FetchTimeoutError(120000)); + } + return Promise.resolve(makeResponse(VALID_JSON)); + }); + + const result = await postPkgseerGraphql({ + endpointUrl: ENDPOINT, + token: TOKEN, + query: "query { x }", + variables: {}, + fetchFn: asFetchFn(fetchFn), + retryOptions: { + maxRetries: 3, + baseDelayMs: 100, + }, + }); + + expect(result.status).toBe(200); + expect(fetchFn).toHaveBeenCalledTimes(2); // 1 failure + 1 success + }); + + it("respects maxRetries option", async () => { + const fetchFn = mock(() => Promise.reject(new Error("Network error"))); + + await expect( + postPkgseerGraphql({ + endpointUrl: ENDPOINT, + token: TOKEN, + query: "query { x }", + variables: {}, + fetchFn: asFetchFn(fetchFn), + retryOptions: { + maxRetries: 2, + baseDelayMs: 100, + }, + }), + ).rejects.toThrow(PkgseerTransportError); + + // Initial attempt + 2 retries = 3 calls total + expect(fetchFn).toHaveBeenCalledTimes(3); + }); + + it("emits debug log on retry attempts", async () => { + process.env.GITHITS_DEBUG = "pkg-graphql"; + + let callCount = 0; + const fetchFn = mock(() => { + callCount++; + if (callCount <= 1) { + return Promise.reject(new Error("Network error")); + } + return Promise.resolve(makeResponse(VALID_JSON)); + }); + + const stderrLines: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrLines.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stderr.write; + + try { + await postPkgseerGraphql({ + endpointUrl: ENDPOINT, + token: TOKEN, + query: "query { x }", + variables: {}, + fetchFn: asFetchFn(fetchFn), + retryOptions: { + maxRetries: 3, + baseDelayMs: 100, + }, + }); + } finally { + process.stderr.write = originalWrite; + } + + const combined = stderrLines.join(""); + expect(combined).toContain('"event":"retry"'); + expect(combined).toContain('"attempt":1'); + }); + }); }); From c724ee8290411c5468a80e1560f905cc2db471ec Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:35:37 +0530 Subject: [PATCH 07/15] feat: add retry support to GitHits REST service --- .../src/services/githits-service.ts | 132 ++++++++++++++---- 1 file changed, 104 insertions(+), 28 deletions(-) diff --git a/packages/core-internal/src/services/githits-service.ts b/packages/core-internal/src/services/githits-service.ts index b45c7aa..bf0e94e 100644 --- a/packages/core-internal/src/services/githits-service.ts +++ b/packages/core-internal/src/services/githits-service.ts @@ -1,6 +1,8 @@ import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithTimeout, + type RetryFetchOptions, + retryFetchWithTimeout, } from "../shared/fetch-timeout.js"; import type { ClientHeaderBuilder } from "../shared/request-headers.js"; import { withTelemetrySpan } from "../shared/telemetry.js"; @@ -100,6 +102,20 @@ export interface GitHitsServiceRuntimeOptions { userAgent?: string; } +/** + * Retry configuration for HTTP requests. + */ +export interface RetryConfig { + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Base delay in milliseconds for exponential backoff (default: 1000) */ + baseDelayMs?: number; + /** Maximum delay in milliseconds (default: 30000) */ + maxDelayMs?: number; + /** Whether to add jitter to delay (default: true) */ + jitter?: boolean; +} + /** * Service interface for GitHits REST API. */ @@ -127,24 +143,52 @@ export class GitHitsServiceImpl implements GitHitsService { private readonly fetchFn?: typeof fetch, private readonly fetchTimeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS, private readonly runtime: GitHitsServiceRuntimeOptions = {}, + private readonly retryConfig?: RetryConfig, ) {} async search(params: SearchParams): Promise { return withTelemetrySpan("githits.search.request", async () => { - const response = await fetchWithTimeout( - `${this.apiUrl}/search`, - { - method: "POST", - headers: this.headers(), - body: JSON.stringify({ - query: params.query, - language: params.language, - license_mode: params.licenseMode ?? "strict", - include_explanation: params.includeExplanation ?? false, - }), - }, - this.fetchOptions(), - ); + const fetchFn = async (): Promise => { + return fetchWithTimeout( + `${this.apiUrl}/search`, + { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ + query: params.query, + language: params.language, + license_mode: params.licenseMode ?? "strict", + include_explanation: params.includeExplanation ?? false, + }), + }, + this.fetchOptions(), + ); + }; + + let response: Response; + if (this.retryConfig) { + // Search POST is idempotent (same query = same result) + response = await retryFetchWithTimeout( + `${this.apiUrl}/search`, + { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ + query: params.query, + language: params.language, + license_mode: params.licenseMode ?? "strict", + include_explanation: params.includeExplanation ?? false, + }), + }, + { + ...this.fetchOptions(), + ...this.retryConfig, + idempotent: true, + }, + ); + } else { + response = await fetchFn(); + } if (!response.ok) { throw await this.createError(response); @@ -156,13 +200,29 @@ export class GitHitsServiceImpl implements GitHitsService { async getLanguages(): Promise { return withTelemetrySpan("githits.languages.request", async () => { - const response = await fetchWithTimeout( - `${this.apiUrl}/languages`, - { - headers: this.headers(), - }, - this.fetchOptions(), - ); + let response: Response; + if (this.retryConfig) { + // Languages GET is idempotent + response = await retryFetchWithTimeout( + `${this.apiUrl}/languages`, + { + headers: this.headers(), + }, + { + ...this.fetchOptions(), + ...this.retryConfig, + idempotent: true, + }, + ); + } else { + response = await fetchWithTimeout( + `${this.apiUrl}/languages`, + { + headers: this.headers(), + }, + this.fetchOptions(), + ); + } if (!response.ok) { throw await this.createError(response); @@ -178,13 +238,29 @@ export class GitHitsServiceImpl implements GitHitsService { query, limit: String(limit), }); - const response = await fetchWithTimeout( - `${this.apiUrl}/languages?${params.toString()}`, - { - headers: this.headers(), - }, - this.fetchOptions(), - ); + let response: Response; + if (this.retryConfig) { + // Languages search GET is idempotent + response = await retryFetchWithTimeout( + `${this.apiUrl}/languages?${params.toString()}`, + { + headers: this.headers(), + }, + { + ...this.fetchOptions(), + ...this.retryConfig, + idempotent: true, + }, + ); + } else { + response = await fetchWithTimeout( + `${this.apiUrl}/languages?${params.toString()}`, + { + headers: this.headers(), + }, + this.fetchOptions(), + ); + } if (!response.ok) { throw await this.createError(response); From fa1fd7cd1d85f04bbea984c8813315d9b1ced2d9 Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:38:17 +0530 Subject: [PATCH 08/15] feat: pass retryOptions through to GraphQL transport in code-navigation-service --- .../src/services/code-navigation-service.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/core-internal/src/services/code-navigation-service.ts b/packages/core-internal/src/services/code-navigation-service.ts index 2325877..883fd35 100644 --- a/packages/core-internal/src/services/code-navigation-service.ts +++ b/packages/core-internal/src/services/code-navigation-service.ts @@ -1605,6 +1605,12 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { userAgent?: string; clientVersion?: string; } = {}, + private readonly retryConfig?: { + maxRetries?: number; + baseDelayMs?: number; + maxDelayMs?: number; + jitter?: boolean; + }, ) {} private async postGraphqlWithTargetResolutionFallback(input: { @@ -1612,6 +1618,15 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { query: string; variables: Record; }): Promise { + const retryOptions = this.retryConfig + ? { + maxRetries: this.retryConfig.maxRetries, + baseDelayMs: this.retryConfig.baseDelayMs, + maxDelayMs: this.retryConfig.maxDelayMs, + jitter: this.retryConfig.jitter, + } + : undefined; + const response = await postPkgseerGraphql({ endpointUrl: this.codeNavigationUrl, token: input.token, @@ -1620,6 +1635,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, userAgent: this.runtime.userAgent, + retryOptions, }); if (response.status < 200 || response.status >= 300) return response; if (!hasSchemaMismatchErrors(response.parsedBody)) return response; @@ -1638,6 +1654,7 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, userAgent: this.runtime.userAgent, + retryOptions, }); if (!hasSchemaMismatchErrors(fallbackResponse.parsedBody)) { return fallbackResponse; From edee39f203f741a4f6c08188d6e50fbc3889b360 Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:42:28 +0530 Subject: [PATCH 09/15] feat: add retry support to package-intelligence-service --- .../services/package-intelligence-service.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/core-internal/src/services/package-intelligence-service.ts b/packages/core-internal/src/services/package-intelligence-service.ts index 94eb235..0f9af17 100644 --- a/packages/core-internal/src/services/package-intelligence-service.ts +++ b/packages/core-internal/src/services/package-intelligence-service.ts @@ -1745,8 +1745,31 @@ export class PackageIntelligenceServiceImpl userAgent?: string; clientVersion?: string; } = {}, + private readonly retryConfig?: { + maxRetries?: number; + baseDelayMs?: number; + maxDelayMs?: number; + jitter?: boolean; + }, ) {} + private getRetryOptions(): + | { + maxRetries?: number; + baseDelayMs?: number; + maxDelayMs?: number; + jitter?: boolean; + } + | undefined { + if (!this.retryConfig) return undefined; + return { + maxRetries: this.retryConfig.maxRetries, + baseDelayMs: this.retryConfig.baseDelayMs, + maxDelayMs: this.retryConfig.maxDelayMs, + jitter: this.retryConfig.jitter, + }; + } + async packageSummary(params: PackageSummaryParams): Promise { return withTelemetrySpan("pkg-intel.summary.request", () => executeWithTokenRefresh({ @@ -1775,6 +1798,7 @@ export class PackageIntelligenceServiceImpl fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, userAgent: this.runtime.userAgent, + retryOptions: this.getRetryOptions(), }); } catch (cause) { if (cause instanceof PkgseerTransportError) { @@ -2130,6 +2154,7 @@ export class PackageIntelligenceServiceImpl fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, userAgent: this.runtime.userAgent, + retryOptions: this.getRetryOptions(), }); } catch (cause) { if (cause instanceof PkgseerTransportError) { @@ -2281,6 +2306,7 @@ export class PackageIntelligenceServiceImpl fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, userAgent: this.runtime.userAgent, + retryOptions: this.getRetryOptions(), }); } catch (cause) { if (cause instanceof PkgseerTransportError) { @@ -2343,6 +2369,7 @@ export class PackageIntelligenceServiceImpl fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, userAgent: this.runtime.userAgent, + retryOptions: this.getRetryOptions(), }); } catch (cause) { if (cause instanceof PkgseerTransportError) { @@ -2660,6 +2687,7 @@ export class PackageIntelligenceServiceImpl fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, userAgent: this.runtime.userAgent, + retryOptions: this.getRetryOptions(), }); } catch (cause) { if (cause instanceof PkgseerTransportError) { @@ -2778,6 +2806,7 @@ export class PackageIntelligenceServiceImpl fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, userAgent: this.runtime.userAgent, + retryOptions: this.getRetryOptions(), }); } catch (cause) { if (cause instanceof PkgseerTransportError) { @@ -2878,6 +2907,7 @@ export class PackageIntelligenceServiceImpl fetchFn: this.fetchFn, clientHeaders: this.runtime.clientHeaders, userAgent: this.runtime.userAgent, + retryOptions: this.getRetryOptions(), }); } catch (cause) { if (cause instanceof PkgseerTransportError) { From 3481f49a74b1a39b28e59189aeb2d1e2d20bceb7 Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:43:01 +0530 Subject: [PATCH 10/15] feat: add retry configuration with env var overrides to config.ts --- packages/core-internal/src/services/config.ts | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/core-internal/src/services/config.ts b/packages/core-internal/src/services/config.ts index 1299537..9a8d3b2 100644 --- a/packages/core-internal/src/services/config.ts +++ b/packages/core-internal/src/services/config.ts @@ -44,8 +44,71 @@ export function getCodeNavigationUrl(): string { } /** - * Get API token from environment variable (for CI/automation). + * API token from environment variable (for CI/automation). */ export function getEnvApiToken(): string | undefined { return process.env.GITHITS_API_TOKEN; } + +// --------------------------------------------------------------------------- +// Retry configuration +// --------------------------------------------------------------------------- + +export interface RetryConfig { + /** Maximum number of retry attempts (default: 3) */ + maxRetries: number; + /** Base delay in milliseconds for exponential backoff (default: 1000) */ + baseDelayMs: number; + /** Maximum delay in milliseconds (default: 30000) */ + maxDelayMs: number; + /** Whether to add jitter to delay (default: true) */ + jitter: boolean; +} + +const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxRetries: 3, + baseDelayMs: 1000, + maxDelayMs: 30000, + jitter: true, +}; + +/** + * Get retry configuration with environment variable overrides. + * + * Environment variables: + * - `GITHITS_RETRY_MAX` — maximum retry attempts + * - `GITHITS_RETRY_BASE_DELAY_MS` — base delay in milliseconds + * - `GITHITS_RETRY_MAX_DELAY_MS` — maximum delay in milliseconds + * - `GITHITS_RETRY_JITTER` — enable/disable jitter ("true"/"false") + */ +export function getRetryConfig(): RetryConfig { + const maxRetries = parseEnvInt( + process.env.GITHITS_RETRY_MAX, + DEFAULT_RETRY_CONFIG.maxRetries, + ); + const baseDelayMs = parseEnvInt( + process.env.GITHITS_RETRY_BASE_DELAY_MS, + DEFAULT_RETRY_CONFIG.baseDelayMs, + ); + const maxDelayMs = parseEnvInt( + process.env.GITHITS_RETRY_MAX_DELAY_MS, + DEFAULT_RETRY_CONFIG.maxDelayMs, + ); + const jitter = parseEnvBool( + process.env.GITHITS_RETRY_JITTER, + DEFAULT_RETRY_CONFIG.jitter, + ); + + return { maxRetries, baseDelayMs, maxDelayMs, jitter }; +} + +function parseEnvInt(value: string | undefined, fallback: number): number { + if (value === undefined) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function parseEnvBool(value: string | undefined, fallback: boolean): boolean { + if (value === undefined) return fallback; + return value.toLowerCase() === "true"; +} From af8c008f0b1510c3fa8acccf0c028e4159bdc5a1 Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:43:29 +0530 Subject: [PATCH 11/15] feat: add retry telemetry helper for tracking retry attempts --- .../core-internal/src/shared/telemetry.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/core-internal/src/shared/telemetry.ts b/packages/core-internal/src/shared/telemetry.ts index 683282c..410c560 100644 --- a/packages/core-internal/src/shared/telemetry.ts +++ b/packages/core-internal/src/shared/telemetry.ts @@ -157,6 +157,36 @@ export function flushTelemetry(exitCode: number = 0): void { telemetryCollector.flush(exitCode); } +// --------------------------------------------------------------------------- +// Retry telemetry helpers +// --------------------------------------------------------------------------- + +/** + * Record retry attempt information on a telemetry span. + * + * @param handle - The span handle to annotate + * @param attempt - Current attempt number (0-based, 0 = first attempt) + * @param maxAttempts - Maximum number of attempts allowed + * @param delayMs - Delay before next retry (undefined on final attempt) + * @param error - The error that triggered the retry + */ +export function recordRetryAttempt( + handle: TelemetrySpanHandle | undefined, + attempt: number, + maxAttempts: number, + delayMs: number | undefined, + error?: Error, +): void { + if (!handle) return; + telemetryCollector.endSpan(handle, { + "retry.attempt": attempt, + "retry.maxAttempts": maxAttempts, + "retry.delayMs": delayMs ?? 0, + "retry.error": error?.name ?? "unknown", + "retry.hasMore": delayMs !== undefined, + }); +} + export function resetTelemetryCollectorForTests( options: TelemetryCollectorOptions = {}, ): void { From cfbaee2d8fd613c03d2777b73f2637bc7173f02a Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:44:49 +0530 Subject: [PATCH 12/15] feat: add retryAttempts and retryAfter to MCP error envelope --- packages/mcp/src/shared/code-navigation-error-map.ts | 4 ++++ packages/mcp/src/tools/shared.ts | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/packages/mcp/src/shared/code-navigation-error-map.ts b/packages/mcp/src/shared/code-navigation-error-map.ts index f9bbc19..9e38359 100644 --- a/packages/mcp/src/shared/code-navigation-error-map.ts +++ b/packages/mcp/src/shared/code-navigation-error-map.ts @@ -81,6 +81,10 @@ export interface MappedError { * retryability tables. */ retryable?: boolean; + /** Number of retry attempts that were made before failing (0 if no retry) */ + retryAttempts?: number; + /** Suggested delay in milliseconds before retrying (from Retry-After header) */ + retryAfter?: number; details?: MappedErrorDetails; } diff --git a/packages/mcp/src/tools/shared.ts b/packages/mcp/src/tools/shared.ts index f395966..7e704a4 100644 --- a/packages/mcp/src/tools/shared.ts +++ b/packages/mcp/src/tools/shared.ts @@ -52,6 +52,10 @@ interface ToolErrorEnvelope { error: string; code: string; retryable: boolean; + /** Number of retry attempts that were made before failing (0 if no retry) */ + retryAttempts?: number; + /** Suggested delay in milliseconds before retrying (from Retry-After header) */ + retryAfter?: number; details?: MappedError["details"] | { action: string }; } @@ -69,6 +73,12 @@ export function buildMcpErrorPayload(mapped: MappedError): ToolErrorEnvelope { error: mapped.message, code: mapped.code, retryable: mapped.retryable ?? false, + ...(mapped.retryAttempts !== undefined + ? { retryAttempts: mapped.retryAttempts } + : {}), + ...(mapped.retryAfter !== undefined + ? { retryAfter: mapped.retryAfter } + : {}), ...(mapped.code === "AUTH_REQUIRED" ? { details: { From c44aee167d15f82fd7c92565d3942cb1a9b98d75 Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 18:45:40 +0530 Subject: [PATCH 13/15] docs: add retry guidelines documentation --- docs/guidelines/RETRY_GUIDELINES.md | 209 ++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/guidelines/RETRY_GUIDELINES.md diff --git a/docs/guidelines/RETRY_GUIDELINES.md b/docs/guidelines/RETRY_GUIDELINES.md new file mode 100644 index 0000000..6cac172 --- /dev/null +++ b/docs/guidelines/RETRY_GUIDELINES.md @@ -0,0 +1,209 @@ +# Retry Guidelines + +This document describes the retry behavior for HTTP transport layers in the GitHits CLI. + +## Overview + +The GitHits CLI automatically retries transient network failures with exponential backoff and jitter. This improves reliability when network conditions are imperfect, rate limits are hit, or servers experience temporary issues. + +## Which Errors Trigger Retry + +The following errors are automatically retried: + +| Error Type | Description | +|---|---| +| `FetchTimeoutError` | Request timed out (default 120s) | +| `PkgseerTransportError` | Network failure (DNS, socket, connection reset) | +| HTTP 429 | Rate limited by server | +| HTTP 5xx | Server-side errors (500, 502, 503, etc.) | + +The following errors are **NOT** retried: + +| Error Type | Reason | +|---|---| +| HTTP 4xx (except 429) | Client errors — retrying won't help | +| `AuthenticationError` | Handled by token refresh, not retry | +| `CodeNavigationValidationError` | Invalid input — retrying won't help | +| `CodeNavigationAccessError` | Permission denied — retrying won't help | + +## Retry Strategy + +### Exponential Backoff + +Each retry attempt increases the delay exponentially: + +``` +delay = baseDelayMs * 2^attempt +``` + +For example, with `baseDelayMs=1000`: +- Attempt 1: 1000ms +- Attempt 2: 2000ms +- Attempt 3: 4000ms + +### Jitter + +To prevent thundering herd problems, jitter is applied to each delay: + +``` +delay = delay * (0.5 + Math.random() * 0.5) +``` + +This adds randomness between 50-100% of the calculated delay. + +### Maximum Delay + +Delays are capped at `maxDelayMs` (default: 30000ms) to prevent excessively long waits. + +### Default Configuration + +| Parameter | Default | Description | +|---|---|---| +| `maxRetries` | 3 | Maximum number of retry attempts | +| `baseDelayMs` | 1000 | Base delay in milliseconds | +| `maxDelayMs` | 30000 | Maximum delay in milliseconds | +| `jitter` | true | Enable/disable jitter | + +## Configuration + +### Environment Variables + +You can override retry behavior using environment variables: + +| Variable | Description | Default | +|---|---|---| +| `GITHITS_RETRY_MAX` | Maximum retry attempts | 3 | +| `GITHITS_RETRY_BASE_DELAY_MS` | Base delay in milliseconds | 1000 | +| `GITHITS_RETRY_MAX_DELAY_MS` | Maximum delay in milliseconds | 30000 | +| `GITHITS_RETRY_JITTER` | Enable jitter ("true"/"false") | true | + +Example: +```bash +export GITHITS_RETRY_MAX=5 +export GITHITS_RETRY_BASE_DELAY_MS=2000 +``` + +### Service Configuration + +Each service accepts retry configuration via constructor options: + +```typescript +const service = new CodeNavigationServiceImpl( + endpointUrl, + tokenProvider, + fetchFn, + runtime, + { + maxRetries: 5, + baseDelayMs: 2000, + maxDelayMs: 60000, + jitter: true, + }, +); +``` + +### Request-Level Configuration + +Individual requests can override retry options: + +```typescript +await postPkgseerGraphql({ + endpointUrl, + token, + query, + variables, + retryOptions: { + maxRetries: 1, // Override for this specific request + }, +}); +``` + +## Idempotency + +Retry logic respects idempotency — only requests that produce the same result when retried are eligible for automatic retry. + +| Request Type | Idempotent? | Retried? | +|---|---|---| +| GraphQL POST (queries) | Yes | Yes | +| REST GET | Yes | Yes | +| REST POST /search | Yes | Yes | +| REST POST /feedbacks | No | No | + +The `submitFeedback()` method in `githits-service.ts` is intentionally **not** retried to avoid duplicate submissions. + +## Telemetry + +Retry attempts are tracked in telemetry spans when `GITHITS_TELEMETRY=1` is set: + +- `retry.attempt` — Current attempt number (0-based) +- `retry.maxAttempts` — Maximum attempts allowed +- `retry.delayMs` — Delay before next retry +- `retry.error` — Error name that triggered retry +- `retry.hasMore` — Whether more retries will follow + +## MCP Error Envelope + +When a request fails after all retry attempts, the MCP error envelope includes retry metadata: + +```json +{ + "error": "Request failed after 3 attempts", + "code": "NETWORK", + "retryable": true, + "retryAttempts": 3, + "retryAfter": 5000 +} +``` + +- `retryAttempts` — Number of attempts made (0 if no retry) +- `retryAfter` — Suggested delay in milliseconds before retrying (from Retry-After header) + +## Troubleshooting + +### Disable Retry + +Set `GITHITS_RETRY_MAX=0` to disable retry entirely. + +### Increase Retry Attempts + +Set `GITHITS_RETRY_MAX=5` (or higher) for more aggressive retrying. + +### Debug Retry Behavior + +Enable debug logging to see retry attempts: + +```bash +export GITHITS_DEBUG=code-nav +``` + +This will log retry attempts and delays to stderr. + +### Network Issues + +If you're experiencing frequent retries, check: + +1. Network connectivity +2. DNS resolution +3. Firewall rules +4. VPN configuration +5. Server status at [status.githits.com](https://status.githits.com) + +## For AI Assistants + +When handling GitHits tool calls: + +1. **Check `retryable` flag** — If `true`, the error may resolve on retry +2. **Check `retryAttempts`** — If high, the issue may be persistent +3. **Check `retryAfter`** — Wait at least this long before retrying +4. **Don't retry `AUTH_REQUIRED`** — Ask user to re-authenticate instead +5. **Don't retry `INVALID_ARGUMENT`** — Fix the input instead +6. **Do retry `NETWORK`/`TIMEOUT`/`RATE_LIMITED`** — These are transient + +Example retry logic: +```typescript +if (error.retryable && error.retryAttempts < 3) { + const delay = error.retryAfter ?? 1000; + await sleep(delay); + return retryToolCall(); +} +``` From 06c2eec1320f24e01633576aa2d62cc7f0d6ff52 Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 19:06:22 +0530 Subject: [PATCH 14/15] fix: remove unused imports and fix setTimeout mock for bun test --- .../src/services/githits-service.ts | 14 +++----------- .../src/shared/fetch-timeout.test.ts | 7 +++++-- packages/core-internal/src/shared/retry.test.ts | 16 +++++++--------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/packages/core-internal/src/services/githits-service.ts b/packages/core-internal/src/services/githits-service.ts index bf0e94e..a0b6d56 100644 --- a/packages/core-internal/src/services/githits-service.ts +++ b/packages/core-internal/src/services/githits-service.ts @@ -1,11 +1,11 @@ import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithTimeout, - type RetryFetchOptions, retryFetchWithTimeout, } from "../shared/fetch-timeout.js"; import type { ClientHeaderBuilder } from "../shared/request-headers.js"; import { withTelemetrySpan } from "../shared/telemetry.js"; +import type { RetryConfig } from "./config.js"; /** * Neutral auth-required message for service/core errors. Surface layers append @@ -104,17 +104,9 @@ export interface GitHitsServiceRuntimeOptions { /** * Retry configuration for HTTP requests. + * Re-exported from config.ts for backward compatibility. */ -export interface RetryConfig { - /** Maximum number of retry attempts (default: 3) */ - maxRetries?: number; - /** Base delay in milliseconds for exponential backoff (default: 1000) */ - baseDelayMs?: number; - /** Maximum delay in milliseconds (default: 30000) */ - maxDelayMs?: number; - /** Whether to add jitter to delay (default: true) */ - jitter?: boolean; -} +export type { RetryConfig } from "./config.js"; /** * Service interface for GitHits REST API. diff --git a/packages/core-internal/src/shared/fetch-timeout.test.ts b/packages/core-internal/src/shared/fetch-timeout.test.ts index 13a14d1..948a396 100644 --- a/packages/core-internal/src/shared/fetch-timeout.test.ts +++ b/packages/core-internal/src/shared/fetch-timeout.test.ts @@ -69,8 +69,11 @@ describe("fetchWithTimeout", () => { describe("retryFetchWithTimeout", () => { beforeEach(() => { - // Mock setTimeout to avoid actual delays in tests - globalThis.setTimeout = mock((_fn: () => void, _ms: number) => 0) as never; + // Mock setTimeout to immediately invoke callback (no actual delays) + globalThis.setTimeout = mock((fn: () => void, _ms: number) => { + fn(); + return 0; + }) as never; }); afterEach(() => { diff --git a/packages/core-internal/src/shared/retry.test.ts b/packages/core-internal/src/shared/retry.test.ts index ed0dd96..04c557a 100644 --- a/packages/core-internal/src/shared/retry.test.ts +++ b/packages/core-internal/src/shared/retry.test.ts @@ -1,11 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; -import { FetchTimeoutError, isFetchTimeoutError } from "./fetch-timeout.js"; -import { - calculateDelay, - isRetryableError, - type RetryOptions, - retryWithBackoff, -} from "./retry.js"; +import { FetchTimeoutError } from "./fetch-timeout.js"; +import { calculateDelay, isRetryableError, retryWithBackoff } from "./retry.js"; // Mock PkgseerTransportError for testing class MockPkgseerTransportError extends Error { @@ -139,8 +134,11 @@ describe("calculateDelay", () => { describe("retryWithBackoff", () => { beforeEach(() => { - // Mock setTimeout to avoid actual delays in tests - globalThis.setTimeout = mock((_fn: () => void, _ms: number) => 0) as never; + // Mock setTimeout to immediately invoke callback (no actual delays) + globalThis.setTimeout = mock((fn: () => void, _ms: number) => { + fn(); + return 0; + }) as never; }); afterEach(() => { From dc93debff8e61d2c848fef65f1dbe906bcf8a9e0 Mon Sep 17 00:00:00 2001 From: codeW-Krish Date: Sat, 20 Jun 2026 19:38:00 +0530 Subject: [PATCH 15/15] docs: remove standalone retry guidelines (covered by JSDoc and PR description) --- docs/guidelines/RETRY_GUIDELINES.md | 209 ---------------------------- 1 file changed, 209 deletions(-) delete mode 100644 docs/guidelines/RETRY_GUIDELINES.md diff --git a/docs/guidelines/RETRY_GUIDELINES.md b/docs/guidelines/RETRY_GUIDELINES.md deleted file mode 100644 index 6cac172..0000000 --- a/docs/guidelines/RETRY_GUIDELINES.md +++ /dev/null @@ -1,209 +0,0 @@ -# Retry Guidelines - -This document describes the retry behavior for HTTP transport layers in the GitHits CLI. - -## Overview - -The GitHits CLI automatically retries transient network failures with exponential backoff and jitter. This improves reliability when network conditions are imperfect, rate limits are hit, or servers experience temporary issues. - -## Which Errors Trigger Retry - -The following errors are automatically retried: - -| Error Type | Description | -|---|---| -| `FetchTimeoutError` | Request timed out (default 120s) | -| `PkgseerTransportError` | Network failure (DNS, socket, connection reset) | -| HTTP 429 | Rate limited by server | -| HTTP 5xx | Server-side errors (500, 502, 503, etc.) | - -The following errors are **NOT** retried: - -| Error Type | Reason | -|---|---| -| HTTP 4xx (except 429) | Client errors — retrying won't help | -| `AuthenticationError` | Handled by token refresh, not retry | -| `CodeNavigationValidationError` | Invalid input — retrying won't help | -| `CodeNavigationAccessError` | Permission denied — retrying won't help | - -## Retry Strategy - -### Exponential Backoff - -Each retry attempt increases the delay exponentially: - -``` -delay = baseDelayMs * 2^attempt -``` - -For example, with `baseDelayMs=1000`: -- Attempt 1: 1000ms -- Attempt 2: 2000ms -- Attempt 3: 4000ms - -### Jitter - -To prevent thundering herd problems, jitter is applied to each delay: - -``` -delay = delay * (0.5 + Math.random() * 0.5) -``` - -This adds randomness between 50-100% of the calculated delay. - -### Maximum Delay - -Delays are capped at `maxDelayMs` (default: 30000ms) to prevent excessively long waits. - -### Default Configuration - -| Parameter | Default | Description | -|---|---|---| -| `maxRetries` | 3 | Maximum number of retry attempts | -| `baseDelayMs` | 1000 | Base delay in milliseconds | -| `maxDelayMs` | 30000 | Maximum delay in milliseconds | -| `jitter` | true | Enable/disable jitter | - -## Configuration - -### Environment Variables - -You can override retry behavior using environment variables: - -| Variable | Description | Default | -|---|---|---| -| `GITHITS_RETRY_MAX` | Maximum retry attempts | 3 | -| `GITHITS_RETRY_BASE_DELAY_MS` | Base delay in milliseconds | 1000 | -| `GITHITS_RETRY_MAX_DELAY_MS` | Maximum delay in milliseconds | 30000 | -| `GITHITS_RETRY_JITTER` | Enable jitter ("true"/"false") | true | - -Example: -```bash -export GITHITS_RETRY_MAX=5 -export GITHITS_RETRY_BASE_DELAY_MS=2000 -``` - -### Service Configuration - -Each service accepts retry configuration via constructor options: - -```typescript -const service = new CodeNavigationServiceImpl( - endpointUrl, - tokenProvider, - fetchFn, - runtime, - { - maxRetries: 5, - baseDelayMs: 2000, - maxDelayMs: 60000, - jitter: true, - }, -); -``` - -### Request-Level Configuration - -Individual requests can override retry options: - -```typescript -await postPkgseerGraphql({ - endpointUrl, - token, - query, - variables, - retryOptions: { - maxRetries: 1, // Override for this specific request - }, -}); -``` - -## Idempotency - -Retry logic respects idempotency — only requests that produce the same result when retried are eligible for automatic retry. - -| Request Type | Idempotent? | Retried? | -|---|---|---| -| GraphQL POST (queries) | Yes | Yes | -| REST GET | Yes | Yes | -| REST POST /search | Yes | Yes | -| REST POST /feedbacks | No | No | - -The `submitFeedback()` method in `githits-service.ts` is intentionally **not** retried to avoid duplicate submissions. - -## Telemetry - -Retry attempts are tracked in telemetry spans when `GITHITS_TELEMETRY=1` is set: - -- `retry.attempt` — Current attempt number (0-based) -- `retry.maxAttempts` — Maximum attempts allowed -- `retry.delayMs` — Delay before next retry -- `retry.error` — Error name that triggered retry -- `retry.hasMore` — Whether more retries will follow - -## MCP Error Envelope - -When a request fails after all retry attempts, the MCP error envelope includes retry metadata: - -```json -{ - "error": "Request failed after 3 attempts", - "code": "NETWORK", - "retryable": true, - "retryAttempts": 3, - "retryAfter": 5000 -} -``` - -- `retryAttempts` — Number of attempts made (0 if no retry) -- `retryAfter` — Suggested delay in milliseconds before retrying (from Retry-After header) - -## Troubleshooting - -### Disable Retry - -Set `GITHITS_RETRY_MAX=0` to disable retry entirely. - -### Increase Retry Attempts - -Set `GITHITS_RETRY_MAX=5` (or higher) for more aggressive retrying. - -### Debug Retry Behavior - -Enable debug logging to see retry attempts: - -```bash -export GITHITS_DEBUG=code-nav -``` - -This will log retry attempts and delays to stderr. - -### Network Issues - -If you're experiencing frequent retries, check: - -1. Network connectivity -2. DNS resolution -3. Firewall rules -4. VPN configuration -5. Server status at [status.githits.com](https://status.githits.com) - -## For AI Assistants - -When handling GitHits tool calls: - -1. **Check `retryable` flag** — If `true`, the error may resolve on retry -2. **Check `retryAttempts`** — If high, the issue may be persistent -3. **Check `retryAfter`** — Wait at least this long before retrying -4. **Don't retry `AUTH_REQUIRED`** — Ask user to re-authenticate instead -5. **Don't retry `INVALID_ARGUMENT`** — Fix the input instead -6. **Do retry `NETWORK`/`TIMEOUT`/`RATE_LIMITED`** — These are transient - -Example retry logic: -```typescript -if (error.retryable && error.retryAttempts < 3) { - const delay = error.retryAfter ?? 1000; - await sleep(delay); - return retryToolCall(); -} -```