runner_core: POST /api/v4/runners/reset_authentication_token returns HTTP 500 for unassigned project runners

Summary

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, the API returns an HTTP 500 Internal Server Error. This is a server-side misclassification of a foreseeable, user-correctable client configuration state.

The error surfaces because an unhandled ActiveRecord::RecordInvalid exception propagates all the way to the Grape response layer, rather than being caught and returned as a structured 4xx client error. The result is that every token rotation attempt by a misconfigured runner is logged as a server exception — causing legitimate SLO alerts to fire against what is effectively a client-side misconfiguration.

Feature category: runner_core
Affected endpoint: POST /api/v4/runners/reset_authentication_token (lib/api/ci/runner.rb)
Affected service: app/services/ci/runners/reset_authentication_token_service.rb


Impact

On GitLab Dedicated

This issue has caused repeated WebserviceServicePumaErrorSLOViolation PagerDuty pages across multiple GitLab Dedicated tenants. In each case, on-call engineers investigated and found no real service degradation — the 500s are entirely driven by a single misconfigured runner client retrying aggressively.

Observed on tenant zeroth_brown_tiger (2026-03-05):

  • 957 HTTP 500 responses in a single burst window (~3 seconds, 11:56:47–11:56:50 UTC)
  • All errors from a single runner: meta.client_id: runner/14, gitlab-runner 18.4.0 (darwin/arm64)
  • Same root cause previously seen on another Dedicated tenant (incident #1900, INC-4581)

Per-incident cost:

  • Each occurrence triggers a PagerDuty page and requires an on-call engineer to investigate
  • Investigation time: ~30–60 minutes per incident to rule out genuine service degradation
  • The SLO violation alarm is a false positive — no user-facing impact beyond the runner operator receiving unhelpful 500 errors

Example log entry from production (GitLab v18.8.5):

method:                POST
path:                  /api/v4/runners/reset_authentication_token
status:                500
severity:              INFO    ← request log is INFO, but exception fires a separate ERROR log
exception.class:       ActiveRecord::RecordInvalid
exception.message:     Validation failed: Runner needs to be assigned to at least one project
meta.feature_category: runner_core
meta.client_id:        runner/14
ua:                    gitlab-runner 18.4.0 (18-4-stable; go1.25.1; darwin/arm64)
duration_s:            0.024

The mixed severity: INFO on the request log and a separate ERROR-level exception log for the same event is what causes monitoring to miscount these as server errors.


Recommendation

The POST /api/v4/runners/reset_authentication_token endpoint should return a 4xx response (e.g. 403 Forbidden or 422 Unprocessable Entity) when the runner is in an invalid state (not assigned to any project), rather than allowing an unhandled ActiveRecord::RecordInvalid exception to produce a 500.

See the investigation comment on this issue for a more detailed theory on what may have changed and what a fix could look like.


Verification

Query OpenSearch / Kibana for the affected endpoint returning 500s.

Index: gitlab-*

{
  "query": {
    "bool": {
      "filter": [
        { "term": { "kubernetes.container_name": "webservice" } },
        { "term": { "meta.caller_id.keyword": "POST /api/:version/runners/reset_authentication_token" } },
        { "term": { "status": 500 } }
      ]
    }
  },
  "aggs": {
    "over_time": {
      "date_histogram": { "field": "@timestamp", "fixed_interval": "1h" }
    }
  }
}

Interpretation: If the query returns results with exception.class: ActiveRecord::RecordInvalid and exception.message containing "Runner needs to be assigned to at least one project", the issue is still present. After a fix, this query should return zero 500 results for this endpoint and error combination.

Example

Here's a sanitised log example:

Click to expand log example

{
  "_index": "gitlab-2026-03-05",
  "_type": "_doc",
  "_source": {
    "time": "2026-03-05T12:51:14.199Z",
    "docker": {
      "container_id": "<REDACTED>"
    },
    "kubernetes": {
      "container_name": "webservice",
      "namespace_name": "default",
      "pod_name": "gitlab-webservice-default-<REDACTED>",
      "container_image": "registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ee:v18.8.5",
      "container_image_id": "registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ee@sha256:1a5dc591c952d48bf823c7f2355aac0575bcd13384058d1232217dc73cab7f4b",
      "pod_id": "<REDACTED>",
      "pod_ip": "<REDACTED>",
      "host": "<REDACTED>",
      "labels": {
        "app.kubernetes.io/name": "gitlab",
        "app.kubernetes.io/version": "v18.8.5",
        "chart": "webservice-9.8.5",
        "gitlab.com/webservice-name": "default",
        "heritage": "Helm",
        "pod-template-hash": "64dd7cd7b4",
        "release": "gitlab"
      },
      "master_url": "<REDACTED>",
      "namespace_id": "<REDACTED>",
      "namespace_labels": {
        "kubernetes.io/metadata.name": "default"
      }
    },
    "component": "gitlab",
    "subcomponent": "exceptions_json",
    "severity": "ERROR",
    "correlation_id": "<REDACTED>",
    "meta.caller_id": "POST /api/:version/runners/reset_authentication_token",
    "meta.remote_ip": "<REDACTED>",
    "meta.feature_category": "runner_core",
    "meta.client_id": "runner/<ID>",
    "exception.class": "ActiveRecord::RecordInvalid",
    "exception.message": "Validation failed: Runner needs to be assigned to at least one project",
    "exception.backtrace": [
      "activerecord (7.2.3) lib/active_record/validations.rb:87:in `raise_validation_error'",
      "activerecord (7.2.3) lib/active_record/validations.rb:54:in `save!'",
      "activerecord (7.2.3) lib/active_record/transactions.rb:366:in `block in save!'",
      "activerecord (7.2.3) lib/active_record/transactions.rb:418:in `block (2 levels) in with_transaction_returning_status'",
      "activerecord (7.2.3) lib/active_record/connection_adapters/abstract/transaction.rb:616:in `block in within_new_transaction'",
      "activesupport (7.2.3) lib/active_support/concurrency/null_lock.rb:9:in `synchronize'",
      "activerecord (7.2.3) lib/active_record/connection_adapters/abstract/transaction.rb:613:in `within_new_transaction'",
      "activerecord (7.2.3) lib/active_record/connection_adapters/abstract/database_statements.rb:361:in `transaction'",
      "lib/gitlab/database/load_balancing/connection_proxy.rb:127:in `public_send'",
      "lib/gitlab/database/load_balancing/connection_proxy.rb:127:in `block in write_using_load_balancer'",
      "lib/gitlab/database/load_balancing/load_balancer.rb:141:in `block in read_write'",
      "lib/gitlab/database/load_balancing/load_balancer.rb:229:in `retry_with_backoff'",
      "lib/gitlab/database/load_balancing/load_balancer.rb:131:in `read_write'",
      "lib/gitlab/database/load_balancing/connection_proxy.rb:126:in `write_using_load_balancer'",
      "lib/gitlab/database/load_balancing/connection_proxy.rb:78:in `transaction'",
      "activerecord (7.2.3) lib/active_record/transactions.rb:414:in `block in with_transaction_returning_status'",
      "lib/gitlab/database/load_balancing/setup.rb:57:in `block in setup_connection_proxy'",
      "activerecord (7.2.3) lib/active_record/transactions.rb:410:in `with_transaction_returning_status'",
      "activerecord (7.2.3) lib/active_record/transactions.rb:366:in `save!'",
      "activerecord (7.2.3) lib/active_record/suppressor.rb:56:in `save!'",
      "lib/authn/token_field/base.rb:70:in `reset_token!'",
      "app/models/concerns/token_authenticatable.rb:75:in `block in add_authentication_token_field'",
      "app/services/ci/runners/reset_authentication_token_service.rb:19:in `execute!'",
      "lib/api/ci/runner.rb:144:in `block (2 levels) in <class:Runner>'",
      "grape (2.0.0) lib/grape/endpoint.rb:58:in `call'",
      "grape (2.0.0) lib/grape/endpoint.rb:58:in `block (2 levels) in generate_api_method'",
      "activesupport (7.2.3) lib/active_support/notifications.rb:212:in `instrument'",
      "grape (2.0.0) lib/grape/endpoint.rb:57:in `block in generate_api_method'",
      "grape (2.0.0) lib/grape/endpoint.rb:328:in `execute'",
      "grape (2.0.0) lib/grape/endpoint.rb:260:in `block in run'",
      "activesupport (7.2.3) lib/active_support/notifications.rb:212:in `instrument'",
      "grape (2.0.0) lib/grape/endpoint.rb:240:in `run'",
      "grape (2.0.0) lib/grape/endpoint.rb:316:in `block in build_stack'",
      "grape (2.0.0) lib/grape/middleware/base.rb:36:in `call!'",
      "grape (2.0.0) lib/grape/middleware/base.rb:29:in `call'",
      "grape (2.0.0) lib/grape/middleware/base.rb:36:in `call!'",
      "grape (2.0.0) lib/grape/middleware/base.rb:29:in `call'",
      "lib/gitlab/middleware/ip_address.rb:14:in `block in call'",
      "lib/gitlab/ip_address_state.rb:11:in `with'",
      "lib/gitlab/middleware/ip_address.rb:13:in `call'",
      "grape (2.0.0) lib/grape/middleware/base.rb:36:in `call!'",
      "grape (2.0.0) lib/grape/middleware/base.rb:29:in `call'",
      "grape (2.0.0) lib/grape/middleware/base.rb:36:in `call!'",
      "grape (2.0.0) lib/grape/middleware/base.rb:29:in `call'",
      "lib/api/api_guard.rb:270:in `call'",
      "grape (2.0.0) lib/grape/middleware/base.rb:36:in `call!'",
      "grape (2.0.0) lib/grape/middleware/base.rb:29:in `call'",
      "rack-oauth2 (2.2.1) lib/rack/oauth2/server/resource.rb:20:in `_call'",
      "rack-oauth2 (2.2.1) lib/rack/oauth2/server/resource/bearer.rb:8:in `_call'",
      "rack-oauth2 (2.2.1) lib/rack/oauth2/server/abstract/handler.rb:17:in `call'",
      "grape (2.0.0) lib/grape/middleware/error.rb:39:in `block in call!'",
      "grape (2.0.0) lib/grape/middleware/error.rb:38:in `catch'",
      "grape (2.0.0) lib/grape/middleware/error.rb:38:in `call!'",
      "grape (2.0.0) lib/grape/middleware/base.rb:29:in `call'",
      "grape_logging (1.8.4) lib/grape_logging/middleware/request_logger.rb:60:in `block in call!'",
      "grape_logging (1.8.4) lib/grape_logging/middleware/request_logger.rb:58:in `catch'",
      "grape_logging (1.8.4) lib/grape_logging/middleware/request_logger.rb:58:in `call!'",
      "grape (2.0.0) lib/grape/middleware/base.rb:29:in `call'",
      "rack (2.2.21) lib/rack/head.rb:12:in `call'",
      "grape (2.0.0) lib/grape/endpoint.rb:224:in `call!'",
      "grape (2.0.0) lib/grape/endpoint.rb:218:in `call'",
      "grape (2.0.0) lib/grape/router/route.rb:58:in `exec'",
      "grape (2.0.0) lib/grape/router.rb:120:in `process_route'",
      "grape (2.0.0) lib/grape/router.rb:74:in `block in identity'",
      "grape (2.0.0) lib/grape/router.rb:94:in `transaction'",
      "grape (2.0.0) lib/grape/router.rb:72:in `identity'",
      "grape (2.0.0) lib/grape/router.rb:56:in `block in call'",
      "grape (2.0.0) lib/grape/router.rb:136:in `with_optimization'",
      "grape (2.0.0) lib/grape/router.rb:55:in `call'",
      "grape (2.0.0) lib/grape/api/instance.rb:165:in `call'",
      "grape (2.0.0) lib/grape/api/instance.rb:70:in `call!'",
      "grape (2.0.0) lib/grape/api/instance.rb:65:in `call'",
      "actionpack (7.2.3) lib/action_dispatch/routing/mapper.rb:33:in `block in <class:Constraints>'",
      "actionpack (7.2.3) lib/action_dispatch/routing/mapper.rb:62:in `serve'",
      "actionpack (7.2.3) lib/action_dispatch/journey/router.rb:53:in `block in serve'",
      "config/initializers/action_dispatch_journey_router.rb:52:in `block in find_routes'",
      "config/initializers/action_dispatch_journey_router.rb:25:in `map!'",
      "config/initializers/action_dispatch_journey_router.rb:25:in `find_routes'",
      "actionpack (7.2.3) lib/action_dispatch/journey/router.rb:34:in `serve'",
      "actionpack (7.2.3) lib/action_dispatch/routing/route_set.rb:896:in `call'",
      "gitlab-experiment (1.2.0) lib/gitlab/experiment/middleware.rb:19:in `call'",
      "omniauth (2.1.4) lib/omniauth/strategy.rb:478:in `call_app!'",
      "omniauth-saml (2.2.4) lib/omniauth/strategies/saml.rb:83:in `other_phase'",
      "omniauth (2.1.4) lib/omniauth/strategy.rb:195:in `call!'",
      "omniauth (2.1.4) lib/omniauth/strategy.rb:169:in `call'",
      "flipper (0.28.3) lib/flipper/middleware/memoizer.rb:72:in `memoized_call'",
      "flipper (0.28.3) lib/flipper/middleware/memoizer.rb:37:in `call'",
      "lib/gitlab/metrics/elasticsearch_rack_middleware.rb:16:in `call'",
      "lib/gitlab/middleware/sidekiq_shard_awareness_validation.rb:20:in `block in call'",
      "lib/gitlab/sidekiq_sharding/validator.rb:42:in `enabled'",
      "lib/gitlab/middleware/sidekiq_shard_awareness_validation.rb:20:in `call'",
      "lib/gitlab/middleware/memory_report.rb:13:in `call'",
      "lib/gitlab/middleware/speedscope.rb:13:in `call'",
      "lib/gitlab/database/load_balancing/rack_middleware.rb:23:in `call'",
      "lib/gitlab/middleware/rails_queue_duration.rb:33:in `call'",
      "lib/gitlab/etag_caching/middleware.rb:21:in `call'",
      "lib/gitlab/metrics/rack_middleware.rb:16:in `block in call'",
      "lib/gitlab/metrics/web_transaction.rb:46:in `run'",
      "lib/gitlab/metrics/rack_middleware.rb:16:in `call'",
      "lib/gitlab/middleware/go.rb:21:in `call'",
      "lib/gitlab/middleware/query_analyzer.rb:11:in `block in call'",
      "lib/gitlab/database/query_analyzer.rb:94:in `within'",
      "lib/gitlab/middleware/query_analyzer.rb:11:in `call'",
      "lib/ci/job_token/middleware.rb:11:in `call'",
      "batch-loader (2.0.5) lib/batch_loader/middleware.rb:11:in `call'",
      "rack-attack (6.8.0) lib/rack/attack.rb:105:in `call'",
      "apollo_upload_server (2.1.6) lib/apollo_upload_server/middleware.rb:19:in `call'",
      "lib/gitlab/middleware/multipart.rb:177:in `call'",
      "lib/gitlab/middleware/rack_attack_headers.rb:42:in `call'",
      "rack-attack (6.8.0) lib/rack/attack.rb:129:in `call'",
      "warden (1.2.9) lib/warden/manager.rb:36:in `block in call'",
      "warden (1.2.9) lib/warden/manager.rb:34:in `catch'",
      "warden (1.2.9) lib/warden/manager.rb:34:in `call'",
      "rack-cors (2.0.2) lib/rack/cors.rb:102:in `call'",
      "rack (2.2.21) lib/rack/tempfile_reaper.rb:15:in `call'",
      "rack (2.2.21) lib/rack/etag.rb:27:in `call'",
      "rack (2.2.21) lib/rack/conditional_get.rb:40:in `call'",
      "rack (2.2.21) lib/rack/head.rb:12:in `call'",
      "actionpack (7.2.3) lib/action_dispatch/http/permissions_policy.rb:38:in `call'",
      "actionpack (7.2.3) lib/action_dispatch/http/content_security_policy.rb:38:in `call'",
      "lib/gitlab/middleware/read_only/controller.rb:40:in `call'",
      "lib/gitlab/middleware/read_only.rb:18:in `call'",
      "lib/gitlab/middleware/unauthenticated_session_expiry.rb:18:in `call'",
      "rack (2.2.21) lib/rack/session/abstract/id.rb:266:in `context'",
      "rack (2.2.21) lib/rack/session/abstract/id.rb:260:in `call'",
      "lib/gitlab/middleware/secure_headers.rb:11:in `call'",
      "actionpack (7.2.3) lib/action_dispatch/middleware/cookies.rb:704:in `call'",
      "lib/gitlab/middleware/same_site_cookies.rb:27:in `call'",
      "actionpack (7.2.3) lib/action_dispatch/middleware/callbacks.rb:31:in `block in call'",
      "activesupport (7.2.3) lib/active_support/callbacks.rb:101:in `run_callbacks'",
      "actionpack (7.2.3) lib/action_dispatch/middleware/callbacks.rb:30:in `call'",
      "sentry-rails (5.23.0) lib/sentry/rails/rescued_exception_interceptor.rb:14:in `call'",
      "actionpack (7.2.3) lib/action_dispatch/middleware/debug_exceptions.rb:31:in `call'",
      "lib/gitlab/middleware/path_depth_check.rb:32:in `call'",
      "lib/gitlab/middleware/path_traversal_check.rb:40:in `call'",
      "lib/gitlab/middleware/handle_malformed_strings.rb:19:in `call'",
      "lib/gitlab/middleware/json_validation.rb:189:in `allow_if_validated'",
      "lib/gitlab/middleware/json_validation.rb:170:in `call'",
      "sentry-ruby (5.23.0) lib/sentry/rack/capture_exceptions.rb:30:in `block (2 levels) in call'",
      "sentry-ruby (5.23.0) lib/sentry/hub.rb:299:in `with_session_tracking'",
      "sentry-ruby (5.23.0) lib/sentry-ruby.rb:428:in `with_session_tracking'",
      "sentry-ruby (5.23.0) lib/sentry/rack/capture_exceptions.rb:21:in `block in call'",
      "sentry-ruby (5.23.0) lib/sentry/hub.rb:89:in `with_scope'",
      "sentry-ruby (5.23.0) lib/sentry-ruby.rb:408:in `with_scope'",
      "sentry-ruby (5.23.0) lib/sentry/rack/capture_exceptions.rb:20:in `call'",
      "actionpack (7.2.3) lib/action_dispatch/middleware/show_exceptions.rb:32:in `call'",
      "lib/gitlab/middleware/basic_health_check.rb:25:in `call'",
      "lograge (0.11.2) lib/lograge/rails_ext/rack/logger.rb:15:in `call_app'",
      "railties (7.2.3) lib/rails/rack/logger.rb:29:in `call'",
      "actionpack (7.2.3) lib/action_dispatch/middleware/remote_ip.rb:96:in `call'",
      "lib/gitlab/middleware/handle_ip_spoof_attack_error.rb:25:in `call'",
      "lib/gitlab/middleware/request_context.rb:15:in `call'",
      "lib/gitlab/middleware/webhook_recursion_detection.rb:15:in `call'",
      "request_store (1.7.0) lib/request_store/middleware.rb:19:in `call'",
      "rack (2.2.21) lib/rack/method_override.rb:24:in `call'",
      "rack (2.2.21) lib/rack/runtime.rb:22:in `call'",
      "rack-timeout (0.7.0) lib/rack/timeout/core.rb:154:in `block in call'",
      "rack-timeout (0.7.0) lib/rack/timeout/support/timeout.rb:19:in `timeout'",
      "rack-timeout (0.7.0) lib/rack/timeout/core.rb:153:in `call'",
      "config/initializers/fix_local_cache_middleware.rb:11:in `call'",
      "lib/gitlab/middleware/compressed_json.rb:44:in `call'",
      "actionpack (7.2.3) lib/action_dispatch/middleware/executor.rb:16:in `call'",
      "lib/gitlab/middleware/rack_multipart_tempfile_factory.rb:19:in `call'",
      "rack (2.2.21) lib/rack/sendfile.rb:127:in `call'",
      "lib/gitlab/metrics/requests_rack_middleware.rb:83:in `call'",
      "gitlab-labkit (1.0.1) lib/labkit/middleware/rack.rb:22:in `block in call'",
      "gitlab-labkit (1.0.1) lib/labkit/context.rb:43:in `with_context'",
      "gitlab-labkit (1.0.1) lib/labkit/middleware/rack.rb:21:in `call'",
      "actionpack (7.2.3) lib/action_dispatch/middleware/request_id.rb:33:in `call'",
      "lib/gitlab/middleware/static_assets_authorization.rb:23:in `call'",
      "railties (7.2.3) lib/rails/engine.rb:535:in `call'",
      "railties (7.2.3) lib/rails/railtie.rb:226:in `public_send'",
      "railties (7.2.3) lib/rails/railtie.rb:226:in `method_missing'",
      "lib/gitlab/middleware/release_env.rb:12:in `call'",
      "rack (2.2.21) lib/rack/urlmap.rb:74:in `block in call'",
      "rack (2.2.21) lib/rack/urlmap.rb:58:in `each'",
      "rack (2.2.21) lib/rack/urlmap.rb:58:in `call'",
      "puma (7.1.0) lib/puma/configuration.rb:300:in `call'",
      "puma (7.1.0) lib/puma/request.rb:101:in `block in handle_request'",
      "puma (7.1.0) lib/puma/thread_pool.rb:355:in `with_force_shutdown'",
      "puma (7.1.0) lib/puma/request.rb:100:in `handle_request'",
      "puma (7.1.0) lib/puma/server.rb:503:in `process_client'",
      "puma (7.1.0) lib/puma/server.rb:262:in `block in run'",
      "puma (7.1.0) lib/puma/thread_pool.rb:182:in `block in spawn_thread'"
    ],
    "user.username": null,
    "tags.program": "web",
    "tags.locale": "en",
    "tags.feature_category": "runner_core",
    "tags.correlation_id": "<REDACTED>",
    "@source": "k8s",
    "@component": "k8s",
    "params": null,
    "command": null,
    "error": null,
    "env": null,
    "limit": null,
    "ref": null,
    "id": null,
    "authorized_projects_refresh.rows_added_slice": null,
    "details": null,
    "job_artifact_id": null,
    "project_id": null,
    "meta.indexing.identifier": null,
    "@hostname": "<REDACTED>",
    "fluentd_tag": "<REDACTED>",
    "@timestamp": "2026-03-05T12:51:14.199467222Z"
  },
  "fields": {
    "@timestamp": [
      "2026-03-05T12:51:14.199Z"
    ],
    "time": [
      "2026-03-05T12:51:14.199Z"
    ]
  }
}



Edited by 🤖 GitLab Bot 🤖