instructure/canvas-lms · error · LlmConversation::Errors::ConversationError

No refresh token available for account

Error message

No refresh token available for account

What it means

In v2 auth mode, HttpClient refreshes expired API tokens by POSTing to /token/refresh with the refresh JWT stored at account.settings[:llm_conversation_service][:refresh_jwt_token]. refresh_v2_token! raises ConversationError if that stored refresh token is blank, because it cannot authenticate the refresh call.

Solutions

  1. Seed the account's refresh token: set account.settings[:llm_conversation_service] = { api_jwt_token: ..., refresh_jwt_token: ... } (e.g. by running the initial token exchange) and save the account.
  2. Verify the settings hash shape with account.settings.dig(:llm_conversation_service, :refresh_jwt_token) — fix any key renaming or nesting mismatches.
  3. Re-run whatever bootstrap/provisioning flow issues the first token pair for the account.
  4. Check for code paths that overwrite account.settings[:llm_conversation_service] and preserve refresh_jwt_token.

Example fix

# before (account settings)
account.settings[:llm_conversation_service] = { api_jwt_token: "eyJ..." }
account.save!

# after
account.settings[:llm_conversation_service] = {
  api_jwt_token: "eyJ...",
  refresh_jwt_token: "eyJ..." # required for refresh_v2_token!
}
account.save!
Defensive patterns

Strategy: validation

Validate before calling

refresh_token = account.settings.dig(:llm_conversation_service, :refresh_jwt_token)
raise 'provision account tokens first' if refresh_token.blank?
client = LlmConversation::HttpClient.new(account: account)

Type guard

def v2_tokens_provisioned?(account)
  account.settings.dig(:llm_conversation_service, :refresh_jwt_token).present?
end

Try / catch

begin
  client.get(path)
rescue LlmConversation::Errors::ConversationError => e
  if e.message == "No refresh token available for account"
    LlmConversation::TokenService.provision!(account)
    retry
  end
  raise
end

Prevention

When it happens

Trigger: A v2-auth account (ai_experiences_v2_auth enabled) gets a 401 during request(), triggering refresh_v2_token!, but account.settings[:llm_conversation_service][:refresh_jwt_token] was never set or has been cleared.

Common situations: Fresh account that never completed the initial token exchange, so only the API token (or nothing) was stored; settings hash wiped or overwritten by another integration; the refresh token key stored under a different name/shape after a code change; TokenCache invalidated while the account record lacks the refresh token.

Related errors


AI-assisted analysis of instructure/canvas-lms@1c9f0bb801 (2026-09-15). Data as JSON: /api/errors/40f241cc5b02fa29. Report an issue: GitHub.

Appendix: source

Thrown at lib/llm_conversation/http_client.rb:66

    end

    def post(path, payload: nil)
      request(:post, path, payload:)
    end

    def patch(path, payload: nil)
      request(:patch, path, payload:)
    end

    def delete(path)
      request(:delete, path)
    end

    private

    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"]

View on GitHub (pinned to 1c9f0bb801)