gitlab-ce/app/controllers/glql/base_controller.rb

116 lines
3.8 KiB
Ruby

# frozen_string_literal: true
module Glql
class BaseController < GraphqlController
before_action :check_rate_limit, only: [:execute]
before_action :set_namespace_context, only: [:execute]
GlqlQueryLockedError = Class.new(StandardError)
# Overrides GraphqlController#execute to add rate limiting for GLQL queries.
# When a query times out (raising ActiveRecord::QueryAborted), we increment the
# Gitlab::ApplicationRateLimiter counter. If a second failure occurs within a
# 15-minute window (configured in lib/gitlab/application_rate_limiter.rb),
# the request is throttled. One failure is allowed, but consecutive
# failures within the time window trigger throttling.
def execute
start_time = Gitlab::Metrics::System.monotonic_time
::Gitlab::Database::LoadBalancing::SessionMap.use_replica_if_available do
super
end
rescue StandardError => error
# We catch all errors here so they are tracked by SLIs.
# But we only increment the rate limiter failure count for ActiveRecord::QueryAborted.
increment_rate_limit_counter if error.is_a?(ActiveRecord::QueryAborted)
raise error
ensure
increment_glql_sli(
duration_s: Gitlab::Metrics::System.monotonic_time - start_time,
error_type: error_type_from(error)
)
end
rescue_from GlqlQueryLockedError do |exception|
log_exception(exception)
render_error(exception.message, status: :forbidden)
end
private
# When `set_current_context` in app/controllers/application_controller.rb calls
# `to_lazy_hash` on Gitlab::ApplicationContext, the meta fields (meta.project and
# meta.root_namespace) will be populated using @group or @project variables.
def set_namespace_context
@project ||= Project.find_by_full_path(permitted_params[:project]) if permitted_params[:project].present?
@group ||= Group.find_by_full_path(permitted_params[:group]) if permitted_params[:group].present?
end
# Overrides GraphqlController#permitted_params to permit project and group params
def permitted_standalone_query_params
params.permit(:query, :operationName, :remove_deprecated, :group, :project, variables: {})
end
def logs
graphql_logs = super.presence || [{}]
graphql_logs.map do |log|
log.merge(
glql_referer: request.headers["Referer"],
glql_query_sha: query_sha
)
end
end
def check_rate_limit
return unless Gitlab::ApplicationRateLimiter.peek(:glql, scope: query_sha)
raise GlqlQueryLockedError,
'Query temporarily blocked due to repeated timeouts. Please try again later or narrow your search scope.'
end
def increment_rate_limit_counter
Gitlab::ApplicationRateLimiter.throttled?(:glql, scope: query_sha)
end
def query_sha
@query_sha ||= Digest::SHA256.hexdigest(permitted_params[:query].to_s)
end
def increment_glql_sli(duration_s:, error_type:)
query_urgency = Gitlab::EndpointAttributes::Config::REQUEST_URGENCIES.fetch(:low)
labels = {
endpoint_id: ::Gitlab::ApplicationContext.current_context_attribute(:caller_id),
feature_category: ::Gitlab::ApplicationContext.current_context_attribute(:feature_category),
query_urgency: query_urgency.name
}
Gitlab::Metrics::GlqlSlis.record_error(
labels: labels.merge(error_type: error_type),
error: error_type.present?
)
return if error_type
Gitlab::Metrics::GlqlSlis.record_apdex(
labels: labels.merge(error_type: nil),
success: duration_s <= query_urgency.duration
)
end
def error_type_from(exception)
return unless exception
case exception
when ActiveRecord::QueryAborted
:query_aborted
else
:other
end
end
end
end