instructure/canvas-lms · error · RuntimeError

Unable to start import for external tool #

Error message

Unable to start import for external tool #{@tool.name} (#{response.code})

What it means

Raised by Lti::ContentMigrationService::Importer#send_imported_content when the POST to the tool's content_migration.import_start_url returns an HTTP status outside 200-201. Canvas expected the tool to accept the imported content and respond with a status_url, but the tool rejected or failed the request. The tool name and HTTP status code are included to identify which integration and what kind of failure.

Solutions

  1. Check the HTTP code in the message: 401/403 → fix the tool's credentials/shared secret; 404 → fix import_start_url; 5xx → investigate the tool's server.
  2. Verify the tool's content_migration.import_start_url in the ContextExternalTool settings matches the vendor's documented endpoint.
  3. Confirm the tool still supports content migration (content_migration_configured?) and its configuration XML is current.
  4. Retry the course import — transient 5xx may succeed on a second attempt.
  5. Contact the tool vendor with the status code and timestamp if the configuration is correct.

Example fix

# before: stale endpoint in tool config
settings: { content_migration: { import_start_url: 'https://tool.example.com/old/import' } }
# after
settings: { content_migration: { import_start_url: 'https://tool.example.com/api/v2/import' } }
Defensive patterns

Strategy: validation

Validate before calling

# before import, validate tool config and endpoint reachability
tool = course.context_external_tools.find_by(id: original_tool_id)
raise 'tool missing' unless tool&.content_migration_configured?
import_url = tool.settings.dig(:content_migration, :import_start_url)
raise 'import_start_url missing' unless import_url.present?
uri = URI(import_url)
res = Net::HTTP.start(uri.host, uri.port, open_timeout: 5) { |h| h.head('/') }
raise "tool endpoint returned #{res.code}" unless %w[200 201 302 401].include?(res.code) # 401 still proves host is up

Try / catch

begin
  importer.send_imported_content(course, migration, content)
rescue RuntimeError => e
  if e.message =~ /Unable to start import for external tool .+ \((\d+)\)/
    status = Regexp.last_match(1).to_i
    case status
    when 401, 403 then notify_admin('re-authenticate external tool')
    when 404      then notify_admin('fix import_start_url configuration')
    else               schedule_retry
    end
  else
    raise
  end
end

Prevention

When it happens

Trigger: send_imported_content posts the exported content to import_start_url and the tool replies 4xx/5xx (e.g. 401 bad OAuth signature, 404 wrong import_start_url, 500 tool crash). Any response.code not in (200..201) hits the raise.

Common situations: Tool's import_start_url misconfigured in its settings/XML; tool credentials or LTI shared secret changed so auth fails (401/403); tool endpoint removed or relocated after an upgrade (404); tool server error (5xx) under load; tool does not actually implement content migration import despite configuration.

Understand the failure class

Background: "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained — this error's family across 27 libraries.

Related errors


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

Appendix: source

Thrown at app/models/lti/content_migration_service/importer.rb:59

        @root_account = course.root_account
        load_tool!
        post_body = start_import_post_body(content)
        response = Canvas.retriable(on: Timeout::Error) do
          case import_format
          when JSON_FORMAT
            CanvasHttp.post(import_start_url, base_request_headers, body: post_body.to_json, content_type: "application/json")
          else
            CanvasHttp.post(import_start_url, base_request_headers, form_data: Rack::Utils.build_nested_query(post_body))
          end
        end
        case response.code.to_i
        when (200..201)
          parsed_response = JSON.parse(response.body)
          unless parsed_response.empty?
            @status_url = parsed_response["status_url"]
          end
        else
          raise "Unable to start import for external tool #{@tool.name} (#{response.code})"
        end
        self
      rescue Timeout::Error
        raise "Unable to start import for external tool #{@tool.name}, request timed out."
      end

      def import_completed?
        InstrumentTLSCiphers.without_tls_metrics do
          response = Canvas.retriable(on: Timeout::Error) { CanvasHttp.get(@status_url, base_request_headers) } if @status_url
          if response&.code.to_i == 200
            parsed_response = JSON.parse(response.body)
            @export_status = parsed_response["status"]
            case @export_status
            when SUCCESSFUL_STATUS
              true
            when FAILED_STATUS
              raise parsed_response["message"]
            else

View on GitHub (pinned to 1c9f0bb801)