Fix runner token reset returning 500 for unassigned project runners

What does this MR do and why?

When a project-type runner that is not assigned to any project attempts to rotate its authentication token via POST /api/v4/runners/reset_authentication_token or POST /api/v4/runners/:id/reset_authentication_token, an unhandled ActiveRecord::RecordInvalid exception propagates to the Grape response layer and produces an HTTP 500.

This is a client-side misconfiguration (the runner has no projects assigned), but the 500 response causes false WebserviceServicePumaErrorSLOViolation alerts on GitLab Dedicated tenants — each requiring 30-60 minutes of on-call investigation to rule out genuine service degradation.

This MR rescues ActiveRecord::RecordInvalid in Ci::Runners::ResetAuthenticationTokenService#execute! and returns a ServiceResponse.error with reason: :unprocessable_entity. Both API endpoints now check the service result and return 422 Unprocessable Entity with an actionable error message instead of letting the exception propagate as a 500.

Changes

  • app/services/ci/runners/reset_authentication_token_service.rb: Rescue ActiveRecord::RecordInvalid and return ServiceResponse.error(message:, reason: :unprocessable_entity)
  • lib/api/ci/runner.rb: Check service result and return appropriate error status via render_api_error!(result.message, result.reason)
  • lib/api/ci/runners.rb: Same fix for the POST /runners/:id/reset_authentication_token endpoint
  • Added specs for all three layers (service, both API endpoints)

Note: Authn::Tokens::RunnerAuthenticationToken#revoke! (used by DELETE /api/v4/admin/token) also calls this service. That caller already checks response.success? and will now correctly receive the ServiceResponse.error instead of an unhandled exception — no additional changes needed there.

References

Closes #592412 (closed)

Screenshots or screen recordings

N/A — backend-only change, no UI impact.

How to set up and validate locally

# In rails console

# 1. Create a project runner and then remove its project assignments
project = FactoryBot.create(:project)
runner = FactoryBot.create(:ci_runner, :project, projects: [project])
runner.runner_projects.delete_all

# 2. Verify runner is in invalid state
puts "Runner valid? #{runner.valid?}" # => false
puts "Runner errors: #{runner.errors.full_messages}" # => ["Runner needs to be assigned to at least one project"]

# 3. Test the service — should return error, NOT raise an exception
service = Ci::Runners::ResetAuthenticationTokenService.new(runner: runner, current_user: nil, source: :runner_api)
result = service.execute!

puts "Error? #{result.error?}"       # => true
puts "Message: #{result.message}"     # => "Validation failed: Runner needs to be assigned to at least one project"
puts "Reason: #{result.reason}"       # => :unprocessable_entity

# 4. Verify token was not changed
puts "Token unchanged? #{runner.reload.token == runner.token}" # => true

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Merge request reports

Loading