instructure/canvas-lms · error · LlmConversation::Errors::ConversationError
Token refresh failed
Error message
Token refresh failed
What it means
refresh_v2_token! POSTs to the LLM conversation service's /token/refresh endpoint with the account's refresh JWT. If the HTTP response is not a Net::HTTPSuccess (2xx), the client raises ConversationError 'Token refresh failed', meaning the service refused to rotate the token pair.
Solutions
- Check the refresh endpoint response/status in logs to see why the service rejected it (expired token vs. server error).
- Re-provision the account's token pair: complete a fresh initial token exchange and store both api_jwt_token and refresh_jwt_token in account.settings, then clear LlmConversation::TokenCache for the account.
- Verify Setting llm_conversation_base_url_<region> points to the correct service environment and that the account UUID matches.
- Retry after confirming service health if the cause was a transient 5xx.
Example fix
# before (stale tokens)
# no re-provisioning; every request 401s, refresh also fails
# after: re-run token exchange, then
account.settings[:llm_conversation_service] = {
api_jwt_token: new_result["api_token"],
refresh_jwt_token: new_result["refresh_token"]
}
account.save!
LlmConversation::TokenCache.set_api_token(account, new_result["api_token"]) Defensive patterns
Strategy: retry
Validate before calling
# no pre-call validation possible; refresh failure is server-side
# ensure base URL and account uuid are correct first:
raise 'bad base url' unless Setting.get("llm_conversation_base_url_#{region}", nil).present? Try / catch
begin
client.get(path)
rescue LlmConversation::Errors::ConversationError => e
if e.message == "Token refresh failed"
LlmConversation::TokenService.reprovision!(account)
retry
end
raise
end Prevention
- Schedule proactive token refresh before the refresh token expires
- Monitor /token/refresh failure rates and 5xx from the LLM service
- Confirm account UUIDs match between Canvas and the LLM service
- Pin llm_conversation_base_url settings per region/environment
When it happens
Trigger: The /token/refresh call returns a non-2xx status — typically because the refresh JWT is expired or revoked, the x-account-id header does not match a known account, or the LLM conversation service itself is erroring.
Common situations: Refresh token expired after long inactivity (no grace refresh); tokens rotated elsewhere making the stored refresh token stale; service-side outage or 5xx; account UUID mismatch between Canvas and the LLM service; wrong llm_conversation_base_url Setting pointing at the wrong environment.
Related errors
- No refresh token available for account
- Bearer token not configured for LLM Conversation Service
- Cannot use initial token: account does not have…
- Content Export for Outcomes Service failed
- Error queueing export for Outcomes Service: #
AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15).
Data as JSON: /api/errors/4c211c64f9648c43.
Report an issue: GitHub.
Appendix: source
Thrown at lib/llm_conversation/http_client.rb:81
def refresh_v2_token!
refresh_token = @account.settings.dig(:llm_conversation_service, :refresh_jwt_token)
raise LlmConversation::Errors::ConversationError, "No refresh token available for account" if refresh_token.blank?
uri = URI("#{@base_url}/token/refresh")
http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme.casecmp?("https")
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
end
req = Net::HTTP::Post.new(uri.request_uri,
"Content-Type" => "application/json",
"Authorization" => "Bearer #{refresh_token}",
"x-account-id" => @account.uuid)
response = http.request(req)
raise LlmConversation::Errors::ConversationError, "Token refresh failed" unless response.is_a?(Net::HTTPSuccess)
result = JSON.parse(response.body)
new_api_token = result["api_token"]
new_refresh_token = result["refresh_token"]
@account.settings[:llm_conversation_service] = {
api_jwt_token: new_api_token,
refresh_jwt_token: new_refresh_token
}
@account.save!
LlmConversation::TokenCache.set_api_token(@account, new_api_token)
@bearer_token = new_api_token
end
def resolve_base_url
region = ApplicationController.region
test_cluster = ApplicationController.test_cluster_nameView on GitHub (pinned to 1c9f0bb801)