Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 29 additions & 14 deletions src/eca/llm_providers/copilot.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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)))
Comment thread
emecii marked this conversation as resolved.

:else
(throw (ex-info (format "Error on copilot login: %s" body)
{:status status
:body body})))))))

;; --- Settings-based login (providers/login flow) ---

Expand Down
46 changes: 46 additions & 0 deletions test/eca/llm_providers/copilot_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<html>temporary outage</html>"}
{: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 "<html>bad gateway</html>"})

(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: <html>bad gateway</html>" (ex-message error)))
(is (= {:status 502 :body "<html>bad gateway</html>"} (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
Expand Down
Loading