feat: improve copilot token refresh resilience#213
feat: improve copilot token refresh resilience#213benben17 wants to merge 4 commits intoericc-ch:masterfrom
Conversation
benben17
commented
Mar 22, 2026
There was a problem hiding this comment.
Pull request overview
This PR aims to make GitHub Copilot token retrieval/refresh more resilient to transient network failures by adding retries/timeouts and introducing a token manager intended to centralize refresh behavior.
Changes:
- Added retry + timeout + backoff behavior to GitHub Copilot token fetching.
- Added a new
CopilotTokenManagersingleton with background refresh + retry scheduling and “use cached token if still valid” behavior. - Added a new Responses route handler (currently incomplete in this repo state due to missing dependencies).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
src/services/github/get-copilot-token.ts |
Adds timeout + retry loop with exponential backoff around token fetch. |
src/routes/responses/handler.ts |
Introduces a Responses endpoint handler (but currently references missing modules/files). |
src/lib/copilot-token-manager.ts |
Adds a singleton token manager with auto-refresh, backoff retries, and fallback to cached token. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export const handleResponses = async (c: Context) => { | ||
| await checkRateLimit(state) | ||
|
|
||
| const payload = await c.req.json<ResponsesPayload>() | ||
| logger.debug("Responses request payload:", JSON.stringify(payload)) |
There was a problem hiding this comment.
logger.debug("Responses request payload:", JSON.stringify(payload)) logs the full user request body, which can include sensitive prompt data and can be very large. Consider redacting message content (or logging only metadata like model/tool names) and truncating to a safe size to avoid leaking data and blowing up logs.
| export const handleResponses = async (c: Context) => { | |
| await checkRateLimit(state) | |
| const payload = await c.req.json<ResponsesPayload>() | |
| logger.debug("Responses request payload:", JSON.stringify(payload)) | |
| const summarizePayloadForLog = (payload: ResponsesPayload) => { | |
| return { | |
| model: payload.model, | |
| stream: (payload as { stream?: boolean }).stream, | |
| max_output_tokens: (payload as { max_output_tokens?: number }).max_output_tokens, | |
| tools: | |
| payload.tools && | |
| payload.tools.map((tool) => { | |
| const base = { type: tool.type } | |
| if ("name" in tool && typeof (tool as ResponseTool & { name?: string }).name === "string") { | |
| return { ...base, name: (tool as ResponseTool & { name?: string }).name } | |
| } | |
| return base | |
| }), | |
| messages_count: | |
| Array.isArray((payload as { messages?: unknown[] }).messages) | |
| ? (payload as { messages?: unknown[] }).messages!.length | |
| : undefined, | |
| } | |
| } | |
| export const handleResponses = async (c: Context) => { | |
| await checkRateLimit(state) | |
| const payload = await c.req.json<ResponsesPayload>() | |
| const safePayloadForLog = summarizePayloadForLog(payload) | |
| logger.debug( | |
| "Responses request payload (summary):", | |
| JSON.stringify(safePayloadForLog), | |
| ) |
| // Export singleton instance | ||
| export const copilotTokenManager = new CopilotTokenManager() |
There was a problem hiding this comment.
copilotTokenManager is exported here but not referenced anywhere else in src/ (the server startup still calls setupCopilotToken() in src/lib/token.ts, which keeps its own setInterval refresh). If the intent of this PR is to improve refresh resilience globally, the manager needs to be integrated into the existing token setup / request paths, otherwise these new behaviors won’t take effect.
| const isRetryableNetworkError = (error: unknown): boolean => { | ||
| if (!(error instanceof Error)) return false | ||
| const networkErrorCodes = new Set([ | ||
| "ECONNRESET", | ||
| "ETIMEDOUT", | ||
| "EAI_AGAIN", | ||
| "ECONNREFUSED", | ||
| "ENOTFOUND", | ||
| ]) | ||
| const maybeCode = (error as Error & { code?: unknown }).code | ||
| const code = typeof maybeCode === "string" ? maybeCode : undefined | ||
|
|
||
| if (code && networkErrorCodes.has(code)) return true | ||
|
|
||
| return /socket connection was closed unexpectedly|network|fetch failed|timeout|timed out/i.test( | ||
| error.message, | ||
| ) |
There was a problem hiding this comment.
AbortController timeouts will throw an abort/timeout error, but isRetryableNetworkError doesn’t currently classify common abort errors (e.g. error.name === "AbortError" / message containing "aborted") as retryable. As a result, timeouts triggered by COPILOT_TOKEN_TIMEOUT_MS will typically not be retried even though the code intends to retry network failures.
| import { getConfig, getMappedModel } from "~/lib/config" | ||
| import { createHandlerLogger } from "~/lib/logger" |
There was a problem hiding this comment.
This file imports ~/lib/config, but that module does not exist in the current repo. With the default tsconfig.json (no include/exclude), TypeScript will typecheck this file and the build will fail until the missing module is added or the import is removed/updated.
| import { streamSSE } from "hono/streaming" | ||
|
|
||
| import { getConfig, getMappedModel } from "~/lib/config" | ||
| import { createHandlerLogger } from "~/lib/logger" |
There was a problem hiding this comment.
This file imports ~/lib/logger, but that module does not exist in the current repo. This will cause TypeScript compilation to fail unless the module is added (or the handler is adjusted to use an existing logging utility).
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@benben17 you can use this https://github.com/caozhiyuan/copilot-api (Includes many new features). These features were already included in the previous pull request, but this repository has not been maintained for a long time. |