Skip to content

WIP: add support for ESI floating window rate limits#469

Open
joaomlneto wants to merge 1 commit intomainfrom
esi-rate-limits
Open

WIP: add support for ESI floating window rate limits#469
joaomlneto wants to merge 1 commit intomainfrom
esi-rate-limits

Conversation

@joaomlneto
Copy link
Owner

This pull request introduces a new ESI (EVE Swagger Interface) status indicator and rate limit dashboard to the web app, along with the underlying hooks and schema logic to support real-time ESI rate limit and endpoint status monitoring. It also updates the ESI client code generation to use the latest OpenAPI specification and exposes additional hooks for infinite queries. The most important changes are grouped below.

ESI Client Code Generation and Exports:

  • Updated the ESI client OpenAPI input to use the /meta/openapi.json endpoint with a compatibility date, ensuring up-to-date schema and rate limit metadata.
  • Exposed additional generated hooks for infinite queries and the main client from the ESI client package entrypoint.

ESI Status Indicator and Dashboard Integration:

  • Added a new EsiStatusIndicator component that displays ESI health status and opens a modal with the RateLimitDashboard on click, and integrated it into the main header (HeaderWithMegaMenus). [1] [2] [3] [4]
  • Added a /rate-limits route and page which renders the RateLimitDashboard within the main layout. [1] [2]
  • Added a button to the /status page to open the RateLimitDashboard in a modal for quick access. [1] [2] [3] [4]

ESI Rate Limit Monitoring Hooks and Schema:

  • Implemented useEsiRateLimitSchema to fetch and parse ESI rate limit information from the OpenAPI spec, and register it with the client for use in dashboards and monitoring.
  • Implemented useEsiRateLimitState hook to provide a real-time snapshot of current ESI rate limit state, using a subscription and polling mechanism for up-to-date UI.
  • Update to use new OpenAPI schema (old Swagger schema url discontinued since 2025-07-07).
  • ESI client: support request throttling.
  • Integrate ESI client with client generation.
  • React hooks to introspect client status
  • Client: ESI client dashboard component (for observability)
  • Indicator: ESI client 'health'
  • ESI client parameterization (base url, user agent)
  • Publish as package(s) to NPM

Closes #468

@joaomlneto joaomlneto requested a review from Copilot February 2, 2026 12:40
@joaomlneto joaomlneto self-assigned this Feb 2, 2026
@joaomlneto joaomlneto added enhancement New feature or request help wanted Extra attention is needed labels Feb 2, 2026
@vercel
Copy link

vercel bot commented Feb 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
jitaspace Error Error Feb 2, 2026 0:41am

Request Review

@sonarqubecloud
Copy link

sonarqubecloud bot commented Feb 2, 2026

Quality Gate Failed Quality Gate failed

Failed conditions
1 Security Hotspot

See analysis details on SonarQube Cloud

};

const normalizeEsiUrl = (value: string) => {
if (!value.startsWith(ESI_BASE_URL)) return value;

Check failure

Code scanning / CodeQL

Incomplete URL substring sanitization High

'
https://esi.evetech.net
' may be followed by an arbitrary host name.

Copilot Autofix

AI about 2 months ago

In general, the fix is to stop treating the URL as a plain string for trust decisions and instead parse it with URL, then compare the parsed origin (protocol + hostname + port) against a set of allowed ESI origins. We want to preserve existing behavior (normalizing ESI URLs to the “latest” base and ensuring trailing slashes) while making sure that only URLs genuinely targeting the configured ESI host are normalized.

Concretely, we should:

  1. Parse value in normalizeEsiUrl using the URL constructor.
  2. Similarly parse ESI_BASE_URL and ESI_LATEST_BASE_URL once and derive their origin values.
  3. Replace the startsWith(ESI_BASE_URL) / startsWith(ESI_LATEST_BASE_URL) string checks with origin comparisons using the parsed URL objects.
  4. When we rebuild the normalized URL, use the original pathname/query/search, but force the host/protocol from the trusted ESI base.

This preserves functionality: any URL whose origin matches the trusted ESI origin gets normalized to the latest base and trailing slash; all other URLs are left untouched. We only need modifications in packages/esi-client/src/client.ts around the helper functions and without adding new external dependencies; the standard URL class is already being used (parseUrlPath), so no new imports are needed.


Suggested changeset 1
packages/esi-client/src/client.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/esi-client/src/client.ts b/packages/esi-client/src/client.ts
--- a/packages/esi-client/src/client.ts
+++ b/packages/esi-client/src/client.ts
@@ -306,13 +306,32 @@
 };
 
 const normalizeEsiUrl = (value: string) => {
-  if (!value.startsWith(ESI_BASE_URL)) return value;
-  let url = value;
-  if (!url.startsWith(ESI_LATEST_BASE_URL)) {
-    const rest = url.slice(ESI_BASE_URL.length);
-    url = `${ESI_LATEST_BASE_URL}${rest.startsWith("/") ? "" : "/"}${rest}`;
+  let target: URL;
+  let esiBase: URL;
+  let esiLatestBase: URL;
+  try {
+    target = new URL(value);
+    esiBase = new URL(ESI_BASE_URL);
+    esiLatestBase = new URL(ESI_LATEST_BASE_URL);
+  } catch {
+    // If parsing fails for any of the URLs, do not attempt normalization.
+    return value;
   }
-  return ensureTrailingSlash(url);
+
+  // Only normalize URLs that actually point at the configured ESI origin.
+  if (target.origin !== esiBase.origin && target.origin !== esiLatestBase.origin) {
+    return value;
+  }
+
+  // Build a normalized URL using the "latest" ESI base as origin.
+  const normalized = new URL(target.toString());
+  normalized.protocol = esiLatestBase.protocol;
+  normalized.hostname = esiLatestBase.hostname;
+  normalized.port = esiLatestBase.port;
+  // Ensure path + query are preserved, but with a trailing slash on the path.
+  normalized.pathname = ensureTrailingSlash(normalized.pathname);
+
+  return normalized.toString();
 };
 
 const normalizeEsiRequestConfig = (config: RequestConfig): RequestConfig => {
EOF
@@ -306,13 +306,32 @@
};

const normalizeEsiUrl = (value: string) => {
if (!value.startsWith(ESI_BASE_URL)) return value;
let url = value;
if (!url.startsWith(ESI_LATEST_BASE_URL)) {
const rest = url.slice(ESI_BASE_URL.length);
url = `${ESI_LATEST_BASE_URL}${rest.startsWith("/") ? "" : "/"}${rest}`;
let target: URL;
let esiBase: URL;
let esiLatestBase: URL;
try {
target = new URL(value);
esiBase = new URL(ESI_BASE_URL);
esiLatestBase = new URL(ESI_LATEST_BASE_URL);
} catch {
// If parsing fails for any of the URLs, do not attempt normalization.
return value;
}
return ensureTrailingSlash(url);

// Only normalize URLs that actually point at the configured ESI origin.
if (target.origin !== esiBase.origin && target.origin !== esiLatestBase.origin) {
return value;
}

// Build a normalized URL using the "latest" ESI base as origin.
const normalized = new URL(target.toString());
normalized.protocol = esiLatestBase.protocol;
normalized.hostname = esiLatestBase.hostname;
normalized.port = esiLatestBase.port;
// Ensure path + query are preserved, but with a trailing slash on the path.
normalized.pathname = ensureTrailingSlash(normalized.pathname);

return normalized.toString();
};

const normalizeEsiRequestConfig = (config: RequestConfig): RequestConfig => {
Copilot is powered by AI and may make mistakes. Always verify output.
if (normalizedUrl === config.url) return config;
return { ...config, url: normalizedUrl };
}
if (config.baseURL && config.baseURL.startsWith(ESI_BASE_URL)) {

Check failure

Code scanning / CodeQL

Incomplete URL substring sanitization High

'
https://esi.evetech.net
' may be followed by an arbitrary host name.

Copilot Autofix

AI about 2 months ago

In general, the fix is to stop using string prefix checks (startsWith) to decide whether a URL belongs to the trusted ESI endpoint and instead parse the URL and compare its origin/hostname against a trusted value. This avoids cases where an attacker can prepend a trusted prefix to an untrusted hostname (e.g., https://esi.evetech.net.evil.com) and still pass the check.

Concretely in packages/esi-client/src/client.ts, the problematic checks are:

  • normalizeEsiUrl: if (!value.startsWith(ESI_BASE_URL)) return value;
  • normalizeEsiRequestConfig: if (config.baseURL && config.baseURL.startsWith(ESI_BASE_URL)) { ... }

We should introduce a small helper that, given a URL string, parses it with the standard URL class and verifies that its origin (or at least protocol + hostname) exactly matches the expected ESI base origin. Then:

  • In normalizeEsiUrl, replace the startsWith test with a call to this helper, and compute ESI_LATEST_BASE_URL conversion based on the parsed path instead of substring slicing against ESI_BASE_URL. This prevents a malicious URL that merely starts with the same characters from being handled as an ESI URL.
  • In normalizeEsiRequestConfig, similarly replace the config.baseURL.startsWith(ESI_BASE_URL) condition with the helper so only URLs whose actual origin matches ESI are treated as ESI URLs.

We can implement this entirely within client.ts using the built‑in URL class (available in modern Node and browser environments) without new external libraries or changes to existing imports. The new helper will be defined near the existing URL utility functions (isAbsoluteUrl, ensureTrailingSlash, etc.) to keep the code cohesive. The rest of the logic—ensuring trailing slashes, preserving query strings, and handling ESI version prefixes—will remain the same for valid ESI URLs.

Suggested changeset 1
packages/esi-client/src/client.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/esi-client/src/client.ts b/packages/esi-client/src/client.ts
--- a/packages/esi-client/src/client.ts
+++ b/packages/esi-client/src/client.ts
@@ -305,12 +305,45 @@
   return query ? `${withSlash}?${query}` : withSlash;
 };
 
+/**
+ * Returns true if the given absolute URL has the same origin as ESI_BASE_URL.
+ * Falls back to the previous string-based behavior only for origin comparison,
+ * not for arbitrary prefixes.
+ */
+const isEsiOrigin = (value: string): boolean => {
+  try {
+    const esi = new URL(ESI_BASE_URL);
+    const url = new URL(value);
+    return esi.protocol === url.protocol && esi.host === url.host;
+  } catch {
+    // If parsing fails, treat as non-ESI URL.
+    return false;
+  }
+};
+
 const normalizeEsiUrl = (value: string) => {
-  if (!value.startsWith(ESI_BASE_URL)) return value;
+  if (!isAbsoluteUrl(value) || !isEsiOrigin(value)) return value;
   let url = value;
-  if (!url.startsWith(ESI_LATEST_BASE_URL)) {
-    const rest = url.slice(ESI_BASE_URL.length);
-    url = `${ESI_LATEST_BASE_URL}${rest.startsWith("/") ? "" : "/"}${rest}`;
+  try {
+    const esiLatest = new URL(ESI_LATEST_BASE_URL);
+    const current = new URL(value);
+    // If already using the latest base URL (same origin + pathname prefix), keep as is.
+    if (!current.href.startsWith(ESI_LATEST_BASE_URL)) {
+      // Preserve the path/query after the base ESI path.
+      const esiBase = new URL(ESI_BASE_URL);
+      let restPath = current.pathname;
+      if (restPath.startsWith(esiBase.pathname)) {
+        restPath = restPath.slice(esiBase.pathname.length);
+      }
+      if (!restPath.startsWith("/")) {
+        restPath = `/${restPath}`;
+      }
+      const search = current.search ?? "";
+      url = `${esiLatest.origin}${esiLatest.pathname.replace(/\/?$/, "")}${restPath}${search}`;
+    }
+  } catch {
+    // On any parsing issues, fall back to the original value.
+    url = value;
   }
   return ensureTrailingSlash(url);
 };
@@ -322,7 +353,7 @@
     if (normalizedUrl === config.url) return config;
     return { ...config, url: normalizedUrl };
   }
-  if (config.baseURL && config.baseURL.startsWith(ESI_BASE_URL)) {
+  if (config.baseURL && isAbsoluteUrl(config.baseURL) && isEsiOrigin(config.baseURL)) {
     const normalizedBase = normalizeEsiUrl(config.baseURL);
     const normalizedUrl = ensureTrailingSlash(config.url);
     return {
EOF
@@ -305,12 +305,45 @@
return query ? `${withSlash}?${query}` : withSlash;
};

/**
* Returns true if the given absolute URL has the same origin as ESI_BASE_URL.
* Falls back to the previous string-based behavior only for origin comparison,
* not for arbitrary prefixes.
*/
const isEsiOrigin = (value: string): boolean => {
try {
const esi = new URL(ESI_BASE_URL);
const url = new URL(value);
return esi.protocol === url.protocol && esi.host === url.host;
} catch {
// If parsing fails, treat as non-ESI URL.
return false;
}
};

const normalizeEsiUrl = (value: string) => {
if (!value.startsWith(ESI_BASE_URL)) return value;
if (!isAbsoluteUrl(value) || !isEsiOrigin(value)) return value;
let url = value;
if (!url.startsWith(ESI_LATEST_BASE_URL)) {
const rest = url.slice(ESI_BASE_URL.length);
url = `${ESI_LATEST_BASE_URL}${rest.startsWith("/") ? "" : "/"}${rest}`;
try {
const esiLatest = new URL(ESI_LATEST_BASE_URL);
const current = new URL(value);
// If already using the latest base URL (same origin + pathname prefix), keep as is.
if (!current.href.startsWith(ESI_LATEST_BASE_URL)) {
// Preserve the path/query after the base ESI path.
const esiBase = new URL(ESI_BASE_URL);
let restPath = current.pathname;
if (restPath.startsWith(esiBase.pathname)) {
restPath = restPath.slice(esiBase.pathname.length);
}
if (!restPath.startsWith("/")) {
restPath = `/${restPath}`;
}
const search = current.search ?? "";
url = `${esiLatest.origin}${esiLatest.pathname.replace(/\/?$/, "")}${restPath}${search}`;
}
} catch {
// On any parsing issues, fall back to the original value.
url = value;
}
return ensureTrailingSlash(url);
};
@@ -322,7 +353,7 @@
if (normalizedUrl === config.url) return config;
return { ...config, url: normalizedUrl };
}
if (config.baseURL && config.baseURL.startsWith(ESI_BASE_URL)) {
if (config.baseURL && isAbsoluteUrl(config.baseURL) && isEsiOrigin(config.baseURL)) {
const normalizedBase = normalizeEsiUrl(config.baseURL);
const normalizedUrl = ensureTrailingSlash(config.url);
return {
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds comprehensive ESI (EVE Swagger Interface) rate limiting support to the codebase, including a floating window rate limit implementation, real-time monitoring dashboard, and status indicators. The changes update the ESI client to use the new OpenAPI endpoint with compatibility date handling, implement client-side request throttling with token bucket algorithm, and provide UI components for observability.

Changes:

  • Updated ESI client code generation to use the new /meta/openapi.json endpoint with compatibility date parameter
  • Implemented comprehensive rate limiting logic with floating window algorithm, token reservation, and bucket management
  • Added React hooks for monitoring rate limit state and schema
  • Created a detailed rate limit dashboard UI component with live metrics and configuration display
  • Integrated ESI status indicator in the main header with modal dashboard access

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
packages/esi-client/kubb.config.ts Updated OpenAPI input URL to use new meta endpoint with compatibility date
packages/esi-client/src/client.ts Added comprehensive rate limiting implementation with token buckets, reservation system, and request throttling
packages/esi-client/src/index.ts Exported client and infinite query hooks for external use
apps/web/lib/useEsiRateLimitState.ts Created React hook for subscribing to real-time rate limit state with polling
apps/web/lib/useEsiRateLimitSchema.ts Created React hook for fetching and registering rate limit schema from OpenAPI spec
apps/web/components/RateLimits/RateLimitDashboard.tsx Built comprehensive dashboard UI for monitoring rate limits, buckets, and schema configuration
apps/web/components/EsiStatus/EsiStatusIndicator.tsx Created status indicator component that displays ESI health and opens dashboard modal
apps/web/components/EsiStatus/index.ts Added barrel export for ESI status components
apps/web/pages/status.tsx Added rate limits button to open dashboard modal on status page
apps/web/app/rate-limits/page.tsx Created dedicated rate limits page
apps/web/app/rate-limits/layout.tsx Added layout for rate limits route
apps/web/layouts/MainLayout/HeaderWithMegaMenus/HeaderWithMegaMenus.tsx Integrated ESI status indicator in main header

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


import type { RateLimitSchemaEntry } from "@jitaspace/esi-client";
import { getRateLimitRouteKey, registerRateLimitConfig } from "@jitaspace/esi-client";

Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ESI compatibility date "2025-12-16" is duplicated between packages/esi-client/kubb.config.ts (line 8) and apps/web/lib/useEsiRateLimitSchema.ts (line 8). This creates a maintenance issue where the two values could diverge. Consider extracting this constant to a shared location or configuration file that both files can import, or at minimum, add comments indicating that these values must be kept in sync.

Suggested change
// NOTE: This compatibility date must be kept in sync with the value in
// packages/esi-client/kubb.config.ts (see CodeQL rule about duplicated ESI compatibility date).

Copilot uses AI. Check for mistakes.

const pruneBucket = (bucket: RateLimitBucket, now: number) => {
const cutoff = now - bucket.windowMs;
while (bucket.entries.length > 0 && bucket.entries[0].timestamp <= cutoff) {
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function pruneBucket removes expired entries based on entry.timestamp <= cutoff. This condition uses less-than-or-equal-to, which means entries exactly at the cutoff boundary (timestamp equals now - windowMs) are removed. Since the window should typically include entries from (now - windowMs, now] (exclusive on the left, inclusive on the right), the condition should be strictly less-than (<) instead of less-than-or-equal-to (<=) to avoid prematurely removing entries that are still within the window.

Suggested change
while (bucket.entries.length > 0 && bucket.entries[0].timestamp <= cutoff) {
while (bucket.entries.length > 0 && bucket.entries[0].timestamp < cutoff) {

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +48
useEffect(() => {
if (refreshMs <= 0) return;
const timer = setInterval(() => {
refreshSnapshot();
setTick((tick) => tick + 1);
}, refreshMs);
return () => clearInterval(timer);
}, [refreshMs]);
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hook useEsiRateLimitState uses both useSyncExternalStore for subscription-based updates and a separate setInterval for polling (lines 41-48). However, the polling mechanism only calls refreshSnapshot() and setTick(), which doesn't trigger the external store's subscribers. The setTick state update forces a re-render, but this creates an inconsistent pattern. The snapshot returned from useSyncExternalStore (line 34-38) won't be updated by the polling interval - only by the subscription. Either the polling should be removed (relying only on subscriptions), or the polling should call the subscribers via notifyRateLimitSubscribers() instead of using a separate state update mechanism.

Copilot uses AI. Check for mistakes.
return () => controller.abort();
}, []);

return useMemo(() => state, [state]);
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEsiRateLimitSchema hook returns useMemo(() => state, [state]) on line 191. Since state is already a stable object from useState, wrapping it in useMemo with state as a dependency doesn't provide any benefit - it will return a new memoized value every time state changes anyway. This line should just return state directly, or if memoization of derived values is needed, it should memoize specific computed properties rather than the entire state object.

Copilot uses AI. Check for mistakes.
Comment on lines +496 to +543
const reserveTokens = (
bucket: RateLimitBucket,
expectedTokens: number,
now: number,
) => {
if (expectedTokens <= 0) return { reservation: undefined };
if (bucket.windowMs <= 0 || bucket.maxTokens <= 0) {
return { reservation: undefined };
}

pruneBucket(bucket, now);

const effectiveMaxTokens = getEffectiveMaxTokens(bucket.maxTokens);
const available = effectiveMaxTokens - bucket.usedTokens;
let retryAt = now;

if (available < expectedTokens) {
let needed = expectedTokens - available;
let freed = 0;
for (const entry of bucket.entries) {
freed += entry.tokens;
if (freed >= needed) {
retryAt = Math.max(retryAt, entry.timestamp + bucket.windowMs);
break;
}
}
}

const spacingMs =
bucket.windowMs > 0
? (expectedTokens / effectiveMaxTokens) * bucket.windowMs
: 0;
const spacingAt = Math.max(bucket.nextFreeAt, now);
retryAt = Math.max(retryAt, spacingAt);

if (retryAt > now) {
return { retryAfterMs: retryAt - now };
}

const entry: RateLimitEntry = {
id: (rateLimitEntryId += 1),
timestamp: now,
tokens: expectedTokens,
};
bucket.entries.push(entry);
bucket.usedTokens += expectedTokens;
bucket.nextFreeAt = Math.max(bucket.nextFreeAt, now) + spacingMs;
return { reservation: { bucket, entry, expectedTokens } };
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rate limiting logic uses global shared state (rateLimitBuckets, rateLimitGroups, rateLimitRoutes) without any concurrency control mechanism. In a concurrent environment (e.g., when multiple requests are made simultaneously), this can lead to race conditions. For example, two requests could both call reserveTokens at nearly the same time, both see available tokens, and both reserve tokens, potentially exceeding the effective limit. Similarly, the pruneBucket and token adjustment operations could have race conditions. Consider using a locking mechanism or restructuring the code to handle concurrent access safely, or document that this implementation is only safe for single-threaded JavaScript environments.

Copilot uses AI. Check for mistakes.
Comment on lines +708 to +713
const getTokenCostForStatus = (status: number) => {
if (status >= 200 && status < 300) return 2;
if (status >= 300 && status < 400) return 1;
if (status >= 400 && status < 500) return status === 429 ? 0 : 5;
return 0;
};
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The token cost function getTokenCostForStatus returns hardcoded values (2 for 2xx, 1 for 3xx, 5 for 4xx except 429, 0 for 5xx and 429). These values appear to be assumptions about ESI's rate limiting cost per status code. However, there's no documentation or comment explaining why these specific values are used, and whether they are based on ESI's actual behavior or are estimates. If ESI provides actual token cost information in response headers, that should be used instead of these hardcoded values. If these are intentional defaults, they should be documented with a comment explaining the rationale.

Copilot uses AI. Check for mistakes.
);

export default function RateLimitDashboard() {
const schema = useEsiRateLimitSchema({});
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEsiRateLimitSchema hook is called with an empty object {} as an argument on line 66, but looking at the hook definition, it doesn't accept any parameters (it's defined as useEsiRateLimitSchema(): RateLimitSchemaState). This empty object argument is unnecessary and should be removed.

Suggested change
const schema = useEsiRateLimitSchema({});
const schema = useEsiRateLimitSchema();

Copilot uses AI. Check for mistakes.
</UnstyledButton>
</Tooltip>
);
});
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EsiStatusIndicator component is wrapped in memo() but doesn't have a display name set. This makes debugging more difficult as React DevTools will show it as Memo(Component) instead of a meaningful name. Consider adding EsiStatusIndicator.displayName = 'EsiStatusIndicator' after the component definition.

Suggested change
});
});
EsiStatusIndicator.displayName = "EsiStatusIndicator";

Copilot uses AI. Check for mistakes.
Comment on lines +302 to +305
const [path, query] = value.split("?");
if (path.endsWith("/")) return value;
const withSlash = `${path}/`;
return query ? `${withSlash}?${query}` : withSlash;
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ensureTrailingSlash function splits on ? to separate path and query string, but doesn't handle URLs with hash fragments (#). If a URL contains a hash fragment (e.g., /path#fragment or /path?query#fragment), the fragment will be included in the query variable or lost. While this might not be an issue for ESI API calls (which typically don't use fragments), it could lead to unexpected behavior if used with such URLs. Consider handling hash fragments explicitly or documenting that this function doesn't support them.

Suggested change
const [path, query] = value.split("?");
if (path.endsWith("/")) return value;
const withSlash = `${path}/`;
return query ? `${withSlash}?${query}` : withSlash;
const hashIndex = value.indexOf("#");
const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
const withoutHash = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
const [path, query] = withoutHash.split("?");
if (path.endsWith("/")) return value;
const withSlash = `${path}/`;
const withSlashAndQuery = query ? `${withSlash}?${query}` : withSlash;
return `${withSlashAndQuery}${hash}`;

Copilot uses AI. Check for mistakes.
Comment on lines +958 to +965

if (
response.status === 429 &&
context.reservation &&
context.reservation.bucket.group === resolvedGroup
) {
applyServerRetryAfter(context.reservation.bucket, response.headers, now);
}
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In applyRateLimitResponse, lines 959-965 check if response.status === 429 and the bucket group matches to apply server retry-after. However, this code is redundant because the same check is already performed on lines 954-956 when the reservation bucket matches the resolved group. The condition on line 962 (context.reservation.bucket.group === resolvedGroup) was already true in the if-block at line 911-924, so this additional check will never execute any new logic. This redundant code should be removed.

Suggested change
if (
response.status === 429 &&
context.reservation &&
context.reservation.bucket.group === resolvedGroup
) {
applyServerRetryAfter(context.reservation.bucket, response.headers, now);
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request help wanted Extra attention is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for ESI Rate Limits

2 participants