From 2b066bb79dc68abb321e9f5a3188114644a95609 Mon Sep 17 00:00:00 2001 From: Yifan Chen Date: Wed, 9 Sep 2026 22:49:04 -0700 Subject: [PATCH] fix: retry Copilot token renewal server errors --- CHANGELOG.md | 1 + src/eca/llm_providers/copilot.clj | 43 +++++++++++++++-------- test/eca/llm_providers/copilot_test.clj | 46 +++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bde4b5679..c96782ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Recover Anthropic streaming responses interrupted by transient TLS `bad_record_mac` failures. - BREAKING: `plugins.install` now appends across config layers. Set `plugins.installMode` to `replace` beside the list to exclude inherited plugins as before. +- Retry one transient GitHub Copilot token-renewal gateway/service failure. #551 ## 0.159.0 diff --git a/src/eca/llm_providers/copilot.clj b/src/eca/llm_providers/copilot.clj index 6b294764c..b0119ec20 100644 --- a/src/eca/llm_providers/copilot.clj +++ b/src/eca/llm_providers/copilot.clj @@ -17,6 +17,12 @@ (def ^:private default-client-id "Iv1.b507a08c87ecfe98") +(def ^:private token-renew-max-attempts 2) + +(def ^:private token-renew-retry-delay-ms 250) + +(def ^:private token-renew-retry-statuses #{500 502 503 504}) + (defn ^:private github-base-url [provider-settings] (or (get-in provider-settings [:auth :url]) "https://github.com")) @@ -241,20 +247,29 @@ (defn ^:private oauth-renew-token [provider-settings access-token] (let [token-url (str (github-api-base-url provider-settings) "/copilot_internal/v2/token") - {:keys [status body]} (http/get - token-url - {:headers (merge (auth-headers) - {"authorization" (str "token " access-token)}) - :throw-exceptions? false - :http-client (client/merge-with-global-http-client {}) - :as :json})] - (if-let [token (:token body)] - (cond-> {:api-key token - :expires-at (:expires_at body)} - (get-in body [:endpoints :api]) (assoc :api-url (get-in body [:endpoints :api]))) - (throw (ex-info (format "Error on copilot login: %s" body) - {:status status - :body body}))))) + request-options {:headers (merge (auth-headers) + {"authorization" (str "token " access-token)}) + :throw-exceptions? false + :http-client (client/merge-with-global-http-client {}) + :as :json}] + (loop [attempt 1] + (let [{:keys [status body]} (http/get token-url request-options)] + (cond + (:token body) + (cond-> {:api-key (:token body) + :expires-at (:expires_at body)} + (get-in body [:endpoints :api]) (assoc :api-url (get-in body [:endpoints :api]))) + + (and (token-renew-retry-statuses status) + (< attempt token-renew-max-attempts)) + (do + (Thread/sleep (long token-renew-retry-delay-ms)) + (recur (inc attempt))) + + :else + (throw (ex-info (format "Error on copilot login: %s" body) + {:status status + :body body}))))))) ;; --- Settings-based login (providers/login flow) --- diff --git a/test/eca/llm_providers/copilot_test.clj b/test/eca/llm_providers/copilot_test.clj index 44f1165c2..e0dcb1ccd 100644 --- a/test/eca/llm_providers/copilot_test.clj +++ b/test/eca/llm_providers/copilot_test.clj @@ -120,6 +120,52 @@ :api-url "https://copilot-proxy.ghe.example.com"} result))))))) +(deftest oauth-renew-token-retry-test + (testing "retries one transient server failure" + (let [requests* (atom 0)] + (with-client-proxied {} + (fn handler [_req] + (if (= 1 (swap! requests* inc)) + {:status 503 :body "temporary outage"} + {:status 200 + :body {:token "copilot-api-key" + :expires_at 9999999999}})) + + (is (= {:api-key "copilot-api-key" + :expires-at 9999999999} + (#'llm-providers.copilot/oauth-renew-token test-provider-settings "gh-access-123"))) + (is (= 2 @requests*)))))) + +(deftest oauth-renew-token-server-error-test + (testing "preserves the response body after retrying a gateway error" + (let [requests* (atom 0) + error (with-client-proxied {} + (fn handler [_req] + (swap! requests* inc) + {:status 502 :body "bad gateway"}) + + (try + (#'llm-providers.copilot/oauth-renew-token test-provider-settings "gh-access-123") + nil + (catch Exception e e)))] + (is (= 2 @requests*)) + (is (= "Error on copilot login: bad gateway" (ex-message error))) + (is (= {:status 502 :body "bad gateway"} (ex-data error))))) + + (testing "does not retry other 5xx statuses" + (let [requests* (atom 0) + error (with-client-proxied {} + (fn handler [_req] + (swap! requests* inc) + {:status 511 :body "network authentication required"}) + + (try + (#'llm-providers.copilot/oauth-renew-token test-provider-settings "gh-access-123") + nil + (catch Exception e e)))] + (is (= 1 @requests*)) + (is (= {:status 511 :body "network authentication required"} (ex-data error)))))) + (deftest poll-device-authorization!-test (testing "completes with token data when github authorizes" (let [db* (atom {:auth {"github-copilot" {:step :login/waiting-user-confirmation