Repository: kedacore/keda

Issue:
Admission Webhook blocks ScaledObject with fallback (using AverageValue) 2.17
### Report

We rolled out 2.17.0 in our dev stack yesterday. We are now unable to deploy `ScaledObject` resources with a `fallback`. We receive the following error:

>  admission webhook "vscaledobject.kb.io" denied the request: at least one trigger (that is not cpu or memory) has to have the `AverageValue` type for the fallback to be enabled

These particular scalers are using the `type=redis`. The HPA's generated by the scalers (at least, pre 2.17) contain an `AverageValue` metric so the error does not make sense to me

### Expected Behavior

Rollout should succeed when `fallback` is specified.

### Actual Behavior

Unable to roll out `ScaledObject` with the following error:
>  admission webhook "vscaledobject.kb.io" denied the request: at least one trigger (that is not cpu or memory) has to have the `AverageValue` type for the fallback to be enabled

### Steps to Reproduce the Problem

1. Keda 2.17.0
2. Apply the following manifests

```yaml
apiVersion: v1
kind: Secret
metadata:
  name: my-redis-secret
  namespace: default
type: Opaque
data:
  redis_username: YWRtaW4=
  redis_password: YWRtaW4=
---
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: my-redis-auth
  namespace: default
spec:
  secretTargetRef:
  - parameter: username
    name: my-redis-secret
    key: redis_username
  - parameter: password
    name: my-redis-secret
    key: redis_password
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: my-queue-trigger
  namespace: default
spec:
  cooldownPeriod: 300
  fallback:
    behavior: static
    failureThreshold: 3
    replicas: 1
  maxReplicaCount: 1
  minReplicaCount: 0
  pollingInterval: 30
  scaleTargetRef:
    name: myproject
  triggers:
    - authenticationRef:
        name: my-redis-auth
      metadata:
        databaseIndex: '0'
        enableTLS: 'true'
        listLength: '5'
        listName: q:my:in
        port: '6380'
      type: redis
    - authenticationRef:
        name: my-redis-auth
      metadata:
        databaseIndex: '0'
        enableTLS: 'true'
        listLength: '5'
        listName: q:my:wait
        port: '6380'
      type: redis
```

3. Fail

### Logs from KEDA operator

Webhook logs (there were not logs in the operator)

```
2025-04-08T12:44:45Z    INFO    triggerauthentication-validation-webhook        validating triggerauthentication creation for {
  "kind": "TriggerAuthentication",
  "apiVersion": "keda.sh/v1alpha1",
  "metadata": {
    "name": "my-redis-auth",
    "namespace": "default",
    "uid": "1aa13de3-45a1-47c5-8df3-f707bc66961d",
    "generation": 1,
    "creationTimestamp": "2025-04-08T12:44:45Z",
    "managedFields": [
      {
        "manager": "kubectl",
        "operation": "Apply",
        "apiVersion": "keda.sh/v1alpha1",
        "time": "2025-04-08T12:44:45Z",
        "fieldsType": "FieldsV1",
        "fieldsV1": {
          "f:spec": {
            "f:secretTargetRef": {}
          }
        }
      }
    ]
  },
  "spec": {
    "secretTargetRef": [
      {
        "parameter": "username",
        "name": "my-redis-secret",
        "key": "redis_username"
      },
      {
        "parameter": "password",
        "name": "my-redis-secret",
        "key": "redis_password"
      }
    ]
  },
  "status": {}
}
2025-04-08T12:44:45Z    ERROR   scaledobject-validation-webhook validation error        {"name": "my-queue-trigger", "error": "at least one trigger (that is not cpu or memory) has to have the `AverageValue` type for the fallback to be enabled"}
github.com/kedacore/keda/v2/apis/keda/v1alpha1.verifyFallback
        /workspace/apis/keda/v1alpha1/scaledobject_webhook.go:196
github.com/kedacore/keda/v2/apis/keda/v1alpha1.validateWorkload
        /workspace/apis/keda/v1alpha1/scaledobject_webhook.go:161
github.com/kedacore/keda/v2/apis/keda/v1alpha1.(*ScaledObject).ValidateCreate
        /workspace/apis/keda/v1alpha1/scaledobject_webhook.go:118
github.com/kedacore/keda/v2/apis/keda/v1alpha1.ScaledObjectCustomValidator.ValidateCreate
        /workspace/apis/keda/v1alpha1/scaledobject_webhook.go:90
sigs.k8s.io/controller-runtime/pkg/webhook/admission.(*validatorForType).Handle
        /workspace/vendor/sigs.k8s.io/controller-runtime/pkg/webhook/admission/validator_custom.go:91
sigs.k8s.io/controller-runtime/pkg/webhook/admission.(*Webhook).Handle
        /workspace/vendor/sigs.k8s.io/controller-runtime/pkg/webhook/admission/webhook.go:181
sigs.k8s.io/controller-runtime/pkg/webhook/admission.(*Webhook).ServeHTTP
        /workspace/vendor/sigs.k8s.io/controller-runtime/pkg/webhook/admission/http.go:119
sigs.k8s.io/controller-runtime/pkg/webhook/internal/metrics.InstrumentedHook.InstrumentHandlerInFlight.func1
        /workspace/vendor/github.com/prometheus/client_golang/prometheus/promhttp/instrument_server.go:60
net/http.HandlerFunc.ServeHTTP
        /usr/local/go/src/net/http/server.go:2220
github.com/prometheus/client_golang/prometheus/promhttp.InstrumentHandlerCounter.func1
        /workspace/vendor/github.com/prometheus/client_golang/prometheus/promhttp/instrument_server.go:147
net/http.HandlerFunc.ServeHTTP
        /usr/local/go/src/net/http/server.go:2220
github.com/prometheus/client_golang/prometheus/promhttp.InstrumentHandlerDuration.func2
        /workspace/vendor/github.com/prometheus/client_golang/prometheus/promhttp/instrument_server.go:109
net/http.HandlerFunc.ServeHTTP
        /usr/local/go/src/net/http/server.go:2220
net/http.(*ServeMux).ServeHTTP
        /usr/local/go/src/net/http/server.go:2747
net/http.serverHandler.ServeHTTP
        /usr/local/go/src/net/http/server.go:3210
net/http.(*conn).serve
        /usr/local/go/src/net/http/server.go:2092
```


### KEDA Version

2.17.0

### Kubernetes Version

1.30

### Platform

Microsoft Azure

### Scaler Details

Redis

### Anything else?

This is an example of the HPA generated on the previous version. Note that the metric has a target of `AverageValue`

```yaml
kind: HorizontalPodAutoscaler
apiVersion: autoscaling/v2
metadata:
  name: keda-hpa-my-queue-trigger
  labels:
    scaledobject.keda.sh/name: my-queue-trigger
  ownerReferences:
    - apiVersion: keda.sh/v1alpha1
      kind: ScaledObject
      name: my-queue-trigger
      uid: 41f2d8b7-d614-49a9-a303-e58e4f58996c
      controller: true
      blockOwnerDeletion: true
spec:
  scaleTargetRef:
    kind: Deployment
    name: myproject
    apiVersion: apps/v1
  minReplicas: 1
  maxReplicas: 1
  metrics:
    - type: External
      external:
        metric:
          name: s0-redis-q-my-in
          selector:
            matchLabels:
              scaledobject.keda.sh/name: my-queue-trigger
        target:
          type: AverageValue
          averageValue: '5'
    - type: External
      external:
        metric:
          name: s1-redis-q-my-wait
          selector:
            matchLabels:
              scaledobject.keda.sh/name: my-queue-trigger
        target:
          type: AverageValue
          averageValue: '10'
```

Discussion:
@wozniakjan: there was a good amount of changes in the admission webhook logic related to fallbacks, a change that slipped through the tests must have been merged accidentally, this will be fixed in a 2.17.1.

Meanwhile, you can either disable webhook validation or roll back to 2.16.1, apologies for the inconvenience.@rickbrouwer: Other workaround for the time being is to add `metricType: AverageValue`.

So:
```
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: my-queue-trigger
  namespace: default
spec:
  cooldownPeriod: 300
  fallback:
    behavior: static
    failureThreshold: 3
    replicas: 1
  maxReplicaCount: 1
  minReplicaCount: 0
  pollingInterval: 30
  scaleTargetRef:
    name: myproject
  triggers:
    - authenticationRef:
        name: my-redis-auth
      metadata:
        databaseIndex: '0'
        enableTLS: 'true'
        listLength: '5'
        listName: q:my:in
        port: '6380'
      type: redis
      metricType: AverageValue
    - authenticationRef:
        name: my-redis-auth
      metadata:
        databaseIndex: '0'
        enableTLS: 'true'
        listLength: '5'
        listName: q:my:wait
        port: '6380'
      type: redis
      metricType: AverageValue
```@weijiany: Hi @rickbrouwer will we fix this in next release?@rickbrouwer: > Hi [@rickbrouwer](https://github.com/rickbrouwer) will we fix this in next release?

I expect this fix in 2.17.1. I think release 2.17.1 is relatively soon, i hope a few days@cschindlbeck: I still get an error that might be related to this on Keda 2.17.0

```
Resource: "keda.sh/v1alpha1, Resource=scaledobjects", GroupVersionKind: "keda.sh/v1alpha1, Kind=ScaledObject"
Name: "e4lt-keda-large-messages", Namespace: "services"
for: "./": error when patching "./": admission webhook "vscaledobject.kb.io" denied the request: at least one trigger (that is not cpu or memory) has to have the `AverageValue` type for the fallback to be enabled
```@pinkfloydx33: > I still get an error that might be related to this on Keda 2.17.0

I can confirm that the the example from the OP still does not work on chart version 2.17.0 which is using Keda 2.17.0. The fix went out with [Release 2.17.1](https://github.com/kedacore/keda/releases) however there doesn't seem to be a chart that includes this (nor can I find a docker image for 2.17.1)  (edit: probably because it was only released a few minutes ago)@cschindlbeck: 2.17.1 is released and fixes this!@tyrken: @cschindlbeck are you sure this is fixed in 2.17.1?  We've just tried upgrading again from 2.16.1 and are again getting the errors when attempting to apply the below test resource:

```
$ kubelet apply -f ./test.yml
Error from server (Forbidden): error when creating "./test.yml": admission webhook "vscaledobject.kb.io" denied the request: at least one trigger (that is not cpu or memory) has to have the `AverageValue` type for the fallback to be enabled
$ kubectl get deployment -n mgmt keda-admission-webhooks -o yaml | grep image
        image: ghcr.io/kedacore/keda-admission-webhooks:2.17.1
        imagePullPolicy: Always
```

test.yml:
```yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: test-web
  namespace: test
spec:
  cooldownPeriod: 300
  fallback:
    failureThreshold: 3
    replicas: 2
  maxReplicaCount: 5
  minReplicaCount: 1
  pollingInterval: 30
  scaleTargetRef:
    name: test-web
  triggers:
    - metadata:
        type: Utilization
        value: "50"
      type: cpu
```@cschindlbeck: as far as i can tell it does


```
  triggers:
  - authenticationRef:
      name: name-redacted
    metadata:
      awsRegion: eu-central-1
      queueLength: "5"
      queueURLFromEnv: Storage__Request__QueueName
    type: aws-sqs-queue
```

No trigger with `AverageValue` needed here@tyrken: Apologies, am I misunderstanding the whole situation & that admission hook is now enforcing some requirement on the (seemingly previously fine) metrics config we had?  That besides the `cpu` type trigger we're using we should now be adding something else to enable "Fallback" - whatever that concept means for KEDA?@tyrken: Hmm - now I see it in our own config, please ignore me while I go read docs...@gowthamvadisetti: The issue still doesn't seem to be fully resolved. The following ScaledObject spec is failing with the same error even in KEDA 2.17.1:

```admission webhook "vscaledobject.kb.io" denied the request: at least one trigger (that is not cpu or memory) has to have the AverageValue type for the fallback to be enabled.```

This spec used to work without issues in KEDA 2.16.x. What has changed here, and is there a workaround for this?
Here’s the failing spec:

```
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  labels:
    keda.sh/scale-target-api-version: v1
  name: nginx-autoscaling-scaled-object
spec:
  fallback:
    failureThreshold: 3
    replicas: 3
  maxReplicaCount: 3
  minReplicaCount: 1
  pollingInterval: 30
  scaleTargetRef:
    apiVersion: argoproj.io/v1alpha1
    kind: Rollout
    name: nginx-autoscaling
  triggers:
    - metadata:
        value: '10'
      metricType: Utilization
      type: cpu
```@rickbrouwer: > The issue still doesn't seem to be fully resolved. The following ScaledObject spec is failing with the same error even in KEDA 2.17.1:
> 
> `admission webhook "vscaledobject.kb.io" denied the request: at least one trigger (that is not cpu or memory) has to have the AverageValue type for the fallback to be enabled.`
> 
> This spec used to work without issues in KEDA 2.16.x. What has changed here, and is there a workaround for this? Here’s the failing spec:
> 
> ```
> apiVersion: keda.sh/v1alpha1
> kind: ScaledObject
> metadata:
>   labels:
>     keda.sh/scale-target-api-version: v1
>   name: nginx-autoscaling-scaled-object
> spec:
>   fallback:
>     failureThreshold: 3
>     replicas: 3
>   maxReplicaCount: 3
>   minReplicaCount: 1
>   pollingInterval: 30
>   scaleTargetRef:
>     apiVersion: argoproj.io/v1alpha1
>     kind: Rollout
>     name: nginx-autoscaling
>   triggers:
>     - metadata:
>         value: '10'
>       metricType: Utilization
>       type: cpu
> ```

Fallback only supports scalers with an AverageValue metric target.
At least, that's what it says in the 2.16 code [here](https://github.com/kedacore/keda/blob/release/v2.16/apis/keda/v1alpha1/scaledobject_types.go#L277).

I think there will be support later via a PR that is still open: https://github.com/kedacore/keda/pull/6655@featherbread: It looks like the rejection of `fallback` for ScaledObjects that _only_ include CPU and memory triggers is an intentional change introduced by #6407 and #6538, both listed under "Fixes" in the v2.17.0 CHANGELOG. The updated logic in #6655 also continues to reject that specific case:

```golang
	if !scaledObject.IsUsingModifiers() {
		fallbackValid := false
		for _, trigger := range scaledObject.Spec.Triggers {
			if trigger.Type == cpuString || trigger.Type == memoryString {
				continue
			}
			fallbackValid = true
			break
		}

		if !fallbackValid {
			return fmt.Errorf("at least one trigger (that is not cpu or memory) has to have the `AverageValue` type for the fallback to be enabled")
		}
	}
```

[The documentation back to at least v2.15](https://keda.sh/docs/2.15/reference/scaledobject-spec/#fallback) calls this out: "Thus, it is not supported by the CPU & memory scalers, or by scalers whose metric target type is Value. In these cases, it will assume that fallback is disabled." In other words: this configuration was invalid even in v2.16 and below, but was silently accepted and ignored rather than rejected up front.

This is also distinct from what was originally reported in this issue and fixed in v2.17.1: the webhook wasn't recognizing `AverageValue` as the default metric type on scalers for which `fallback` _is actually valid_.

In other words, it sounds like anyone experiencing this on v2.17.1 and up needs to check our ScaledObject definitions and ensure they don't define a useless `fallback` if they only include CPU and memory triggers. (Unless the KEDA maintainers would be open to some kind of backwards compatibility allowance for these configs, but either way that would be a separate issue.)

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07f371743..50d9b1daf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -72,7 +72,7 @@ To learn more about active deprecations, we recommend checking [GitHub Discussio
 
 ### Fixes
 
-- TODO ([#XXX](https://github.com/kedacore/keda/issues/XXX))
+- **General**: Admission Webhook blocks ScaledObject without metricType with fallback ([#6696](https://github.com/kedacore/keda/issues/6696))
 
 ### Deprecations
 
diff --git a/apis/keda/v1alpha1/scaledobject_types.go b/apis/keda/v1alpha1/scaledobject_types.go
index 84f48dd60..5a7cb3280 100644
--- a/apis/keda/v1alpha1/scaledobject_types.go
+++ b/apis/keda/v1alpha1/scaledobject_types.go
@@ -305,8 +305,13 @@ func CheckFallbackValid(scaledObject *ScaledObject) error {
 			if trigger.Type == cpuString || trigger.Type == memoryString {
 				continue
 			}
-			// If at least one trigger is of the type `AverageValue`, then having fallback is valid.
-			if trigger.MetricType == autoscalingv2.AverageValueMetricType {
+
+			effectiveMetricType := trigger.MetricType
+			if effectiveMetricType == "" {
+				effectiveMetricType = autoscalingv2.AverageValueMetricType
+			}
+
+			if effectiveMetricType == autoscalingv2.AverageValueMetricType {
 				fallbackValid = true
 				break
 			}



Test patch:
diff --git a/apis/keda/v1alpha1/scaledobject_types_test.go b/apis/keda/v1alpha1/scaledobject_types_test.go
new file mode 100644
index 000000000..559e93aa9
--- /dev/null
+++ b/apis/keda/v1alpha1/scaledobject_types_test.go
@@ -0,0 +1,683 @@
+/*
+Copyright 2023 The KEDA Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1alpha1
+
+import (
+	"strings"
+	"testing"
+
+	autoscalingv2 "k8s.io/api/autoscaling/v2"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestCheckFallbackValid(t *testing.T) {
+	tests := []struct {
+		name          string
+		scaledObject  *ScaledObject
+		expectedError bool
+		errorContains string
+	}{
+		{
+			name: "No fallback configured",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: nil,
+					Triggers: []ScaleTriggers{
+						{
+							Type: "couchdb",
+						},
+					},
+				},
+			},
+			expectedError: false,
+		},
+		{
+			name: "Explicit AverageValue metricType - valid",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: &Fallback{
+						FailureThreshold: 3,
+						Replicas:         1,
+					},
+					Triggers: []ScaleTriggers{
+						{
+							Type:       "couchdb",
+							MetricType: autoscalingv2.AverageValueMetricType,
+						},
+					},
+				},
+			},
+			expectedError: false,
+		},
+		{
+			name: "Implicit AverageValue metricType (empty string) - valid",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: &Fallback{
+						FailureThreshold: 3,
+						Replicas:         1,
+					},
+					Triggers: []ScaleTriggers{
+						{
+							Type:       "couchdb",
+							MetricType: "", // Empty string should default to AverageValue
+						},
+					},
+				},
+			},
+			expectedError: false,
+		},
+		{
+			name: "Value metricType - invalid",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: &Fallback{
+						FailureThreshold: 3,
+						Replicas:         1,
+					},
+					Triggers: []ScaleTriggers{
+						{
+							Type:       "couchdb",
+							MetricType: autoscalingv2.ValueMetricType,
+						},
+					},
+				},
+			},
+			expectedError: true,
+			errorContains: "type for the fallback to be enabled",
+		},
+		{
+			name: "Multiple triggers with one valid AverageValue - valid",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: &Fallback{
+						FailureThreshold: 3,
+						Replicas:         1,
+					},
+					Triggers: []ScaleTriggers{
+						{
+							Type:       "prometheus",
+							MetricType: autoscalingv2.ValueMetricType,
+						},
+						{
+							Type:       "couchdb",
+							MetricType: autoscalingv2.AverageValueMetricType,
+						},
+					},
+				},
+			},
+			expectedError: false,
+		},
+		{
+			name: "CPU trigger - invalid",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: &Fallback{
+						FailureThreshold: 3,
+						Replicas:         1,
+					},
+					Triggers: []ScaleTriggers{
+						{
+							Type:       "cpu",
+							MetricType: autoscalingv2.AverageValueMetricType,
+						},
+					},
+				},
+			},
+			expectedError: true,
+			errorContains: "type for the fallback to be enabled",
+		},
+		{
+			name: "Memory trigger - invalid",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: &Fallback{
+						FailureThreshold: 3,
+						Replicas:         1,
+					},
+					Triggers: []ScaleTriggers{
+						{
+							Type:       "memory",
+							MetricType: autoscalingv2.AverageValueMetricType,
+						},
+					},
+				},
+			},
+			expectedError: true,
+			errorContains: "type for the fallback to be enabled",
+		},
+		{
+			name: "Multiple triggers with one CPU and one valid - valid",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: &Fallback{
+						FailureThreshold: 3,
+						Replicas:         1,
+					},
+					Triggers: []ScaleTriggers{
+						{
+							Type:       "cpu",
+							MetricType: autoscalingv2.UtilizationMetricType,
+						},
+						{
+							Type:       "couchdb",
+							MetricType: autoscalingv2.AverageValueMetricType,
+						},
+					},
+				},
+			},
+			expectedError: false,
+		},
+		{
+			name: "Negative FailureThreshold - invalid",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: &Fallback{
+						FailureThreshold: -1,
+						Replicas:         1,
+					},
+					Triggers: []ScaleTriggers{
+						{
+							Type:       "couchdb",
+							MetricType: autoscalingv2.AverageValueMetricType,
+						},
+					},
+				},
+			},
+			expectedError: true,
+			errorContains: "must both be greater than or equal to 0",
+		},
+		{
+			name: "Negative Replicas - invalid",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: &Fallback{
+						FailureThreshold: 3,
+						Replicas:         -1,
+					},
+					Triggers: []ScaleTriggers{
+						{
+							Type:       "couchdb",
+							MetricType: autoscalingv2.AverageValueMetricType,
+						},
+					},
+				},
+			},
+			expectedError: true,
+			errorContains: "must both be greater than or equal to 0",
+		},
+		{
+			name: "Using ScalingModifiers with AverageValue MetricType - valid",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: &Fallback{
+						FailureThreshold: 3,
+						Replicas:         1,
+					},
+					Advanced: &AdvancedConfig{
+						ScalingModifiers: ScalingModifiers{
+							MetricType: autoscalingv2.AverageValueMetricType,
+							Formula:    "x * 2",
+						},
+					},
+					Triggers: []ScaleTriggers{
+						{
+							Type: "couchdb",
+						},
+					},
+				},
+			},
+			expectedError: false,
+		},
+		{
+			name: "Using ScalingModifiers with Value MetricType - invalid",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Fallback: &Fallback{
+						FailureThreshold: 3,
+						Replicas:         1,
+					},
+					Advanced: &AdvancedConfig{
+						ScalingModifiers: ScalingModifiers{
+							MetricType: autoscalingv2.ValueMetricType,
+							Formula:    "x * 2",
+						},
+					},
+					Triggers: []ScaleTriggers{
+						{
+							Type: "couchdb",
+						},
+					},
+				},
+			},
+			expectedError: true,
+			errorContains: "ScaledObject.Spec.Advanced.ScalingModifiers.MetricType must be AverageValue",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			err := CheckFallbackValid(test.scaledObject)
+
+			if test.expectedError && err == nil {
+				t.Error("Expected error but got nil")
+			}
+
+			if !test.expectedError && err != nil {
+				t.Errorf("Expected no error but got: %v", err)
+			}
+
+			if test.expectedError && err != nil && test.errorContains != "" {
+				if !contains(err.Error(), test.errorContains) {
+					t.Errorf("Error message does not contain expected text.\nExpected to contain: %s\nActual: %s", test.errorContains, err.Error())
+				}
+			}
+		})
+	}
+}
+
+func TestHasPausedReplicaAnnotation(t *testing.T) {
+	tests := []struct {
+		name         string
+		annotations  map[string]string
+		expectResult bool
+	}{
+		{
+			name:         "No annotations",
+			annotations:  nil,
+			expectResult: false,
+		},
+		{
+			name:         "Has PausedReplicasAnnotation",
+			annotations:  map[string]string{PausedReplicasAnnotation: "5"},
+			expectResult: true,
+		},
+		{
+			name:         "Has other annotations but not PausedReplicasAnnotation",
+			annotations:  map[string]string{"some-other-annotation": "value"},
+			expectResult: false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			so := &ScaledObject{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: test.annotations,
+				},
+			}
+			result := so.HasPausedReplicaAnnotation()
+			if result != test.expectResult {
+				t.Errorf("Expected HasPausedReplicaAnnotation to return %v, got %v", test.expectResult, result)
+			}
+		})
+	}
+}
+
+func TestHasPausedAnnotation(t *testing.T) {
+	tests := []struct {
+		name         string
+		annotations  map[string]string
+		expectResult bool
+	}{
+		{
+			name:         "No annotations",
+			annotations:  nil,
+			expectResult: false,
+		},
+		{
+			name:         "Has PausedAnnotation only",
+			annotations:  map[string]string{PausedAnnotation: "true"},
+			expectResult: true,
+		},
+		{
+			name:         "Has PausedReplicasAnnotation only",
+			annotations:  map[string]string{PausedReplicasAnnotation: "5"},
+			expectResult: true,
+		},
+		{
+			name:         "Has both annotations",
+			annotations:  map[string]string{PausedAnnotation: "true", PausedReplicasAnnotation: "5"},
+			expectResult: true,
+		},
+		{
+			name:         "Has other annotations but not paused ones",
+			annotations:  map[string]string{"some-other-annotation": "value"},
+			expectResult: false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			so := &ScaledObject{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: test.annotations,
+				},
+			}
+			result := so.HasPausedAnnotation()
+			if result != test.expectResult {
+				t.Errorf("Expected HasPausedAnnotation to return %v, got %v", test.expectResult, result)
+			}
+		})
+	}
+}
+
+func TestNeedToBePausedByAnnotation(t *testing.T) {
+	pausedReplicaCount := int32(5)
+
+	tests := []struct {
+		name               string
+		annotations        map[string]string
+		pausedReplicaCount *int32
+		expectResult       bool
+	}{
+		{
+			name:               "No annotations",
+			annotations:        nil,
+			pausedReplicaCount: nil,
+			expectResult:       false,
+		},
+		{
+			name:               "PausedAnnotation with true value",
+			annotations:        map[string]string{PausedAnnotation: "true"},
+			pausedReplicaCount: nil,
+			expectResult:       true,
+		},
+		{
+			name:               "PausedAnnotation with false value",
+			annotations:        map[string]string{PausedAnnotation: "false"},
+			pausedReplicaCount: nil,
+			expectResult:       false,
+		},
+		{
+			name:               "PausedAnnotation with invalid value",
+			annotations:        map[string]string{PausedAnnotation: "invalid"},
+			pausedReplicaCount: nil,
+			expectResult:       true, // Non-boolean values should default to true
+		},
+		{
+			name:               "PausedReplicasAnnotation with value and status set",
+			annotations:        map[string]string{PausedReplicasAnnotation: "5"},
+			pausedReplicaCount: &pausedReplicaCount,
+			expectResult:       true,
+		},
+		{
+			name:               "PausedReplicasAnnotation with value but no status set",
+			annotations:        map[string]string{PausedReplicasAnnotation: "5"},
+			pausedReplicaCount: nil,
+			expectResult:       false,
+		},
+		{
+			name:               "Both annotations set",
+			annotations:        map[string]string{PausedAnnotation: "true", PausedReplicasAnnotation: "5"},
+			pausedReplicaCount: &pausedReplicaCount,
+			expectResult:       true, // PausedReplicasAnnotation has precedence
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			so := &ScaledObject{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: test.annotations,
+				},
+				Status: ScaledObjectStatus{
+					PausedReplicaCount: test.pausedReplicaCount,
+				},
+			}
+			result := so.NeedToBePausedByAnnotation()
+			if result != test.expectResult {
+				t.Errorf("Expected NeedToBePausedByAnnotation to return %v, got %v", test.expectResult, result)
+			}
+		})
+	}
+}
+
+func TestIsUsingModifiers(t *testing.T) {
+	tests := []struct {
+		name         string
+		scaledObject *ScaledObject
+		expectResult bool
+	}{
+		{
+			name: "No Advanced config",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Advanced: nil,
+				},
+			},
+			expectResult: false,
+		},
+		{
+			name: "Empty ScalingModifiers",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Advanced: &AdvancedConfig{
+						ScalingModifiers: ScalingModifiers{},
+					},
+				},
+			},
+			expectResult: false,
+		},
+		{
+			name: "Has ScalingModifiers formula",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Advanced: &AdvancedConfig{
+						ScalingModifiers: ScalingModifiers{
+							Formula: "x * 2",
+						},
+					},
+				},
+			},
+			expectResult: true,
+		},
+		{
+			name: "Has ScalingModifiers target",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					Advanced: &AdvancedConfig{
+						ScalingModifiers: ScalingModifiers{
+							Target: "100",
+						},
+					},
+				},
+			},
+			expectResult: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			result := test.scaledObject.IsUsingModifiers()
+			if result != test.expectResult {
+				t.Errorf("Expected IsUsingModifiers to return %v, got %v", test.expectResult, result)
+			}
+		})
+	}
+}
+
+func TestCheckReplicaCountBoundsAreValid(t *testing.T) {
+	min1 := int32(1)
+	min2 := int32(2)
+	max5 := int32(5)
+	idle0 := int32(0)
+	idle1 := int32(1)
+	idle2 := int32(2)
+
+	tests := []struct {
+		name          string
+		scaledObject  *ScaledObject
+		expectedError bool
+		errorContains string
+	}{
+		{
+			name: "Valid: min 1, max 5, no idle",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					MinReplicaCount:  &min1,
+					MaxReplicaCount:  &max5,
+					IdleReplicaCount: nil,
+				},
+			},
+			expectedError: false,
+		},
+		{
+			name: "Valid: min 1, max 5, idle 0",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					MinReplicaCount:  &min1,
+					MaxReplicaCount:  &max5,
+					IdleReplicaCount: &idle0,
+				},
+			},
+			expectedError: false,
+		},
+		{
+			name: "Invalid: min 2 > max 1",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					MinReplicaCount: &min2,
+					MaxReplicaCount: &min1,
+				},
+			},
+			expectedError: true,
+			errorContains: "MinReplicaCount=2 must be less than MaxReplicaCount=1",
+		},
+		{
+			name: "Invalid: idle 1 == min 1",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					MinReplicaCount:  &min1,
+					MaxReplicaCount:  &max5,
+					IdleReplicaCount: &idle1,
+				},
+			},
+			expectedError: true,
+			errorContains: "IdleReplicaCount=1 must be less than MinReplicaCount=1",
+		},
+		{
+			name: "Invalid: idle 2 > min 1",
+			scaledObject: &ScaledObject{
+				Spec: ScaledObjectSpec{
+					MinReplicaCount:  &min1,
+					MaxReplicaCount:  &max5,
+					IdleReplicaCount: &idle2,
+				},
+			},
+			expectedError: true,
+			errorContains: "IdleReplicaCount=2 must be less than MinReplicaCount=1",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			err := CheckReplicaCountBoundsAreValid(test.scaledObject)
+
+			if test.expectedError && err == nil {
+				t.Error("Expected error but got nil")
+			}
+
+			if !test.expectedError && err != nil {
+				t.Errorf("Expected no error but got: %v", err)
+			}
+
+			if test.expectedError && err != nil && test.errorContains != "" {
+				if !strings.Contains(err.Error(), test.errorContains) {
+					t.Errorf("Error message does not contain expected text.\nExpected to contain: %s\nActual: %s",
+						test.errorContains, err.Error())
+				}
+			}
+		})
+	}
+}
+
+func TestGetHPAReplicas(t *testing.T) {
+	min0 := int32(0)
+	min5 := int32(5)
+	max10 := int32(10)
+
+	tests := []struct {
+		name            string
+		minReplicaCount *int32
+		maxReplicaCount *int32
+		expectedMin     int32
+		expectedMax     int32
+	}{
+		{
+			name:            "Default min and max",
+			minReplicaCount: nil,
+			maxReplicaCount: nil,
+			expectedMin:     1,   // default minimum
+			expectedMax:     100, // default maximum
+		},
+		{
+			name:            "Custom min, default max",
+			minReplicaCount: &min5,
+			maxReplicaCount: nil,
+			expectedMin:     5,
+			expectedMax:     100,
+		},
+		{
+			name:            "Default min, custom max",
+			minReplicaCount: nil,
+			maxReplicaCount: &max10,
+			expectedMin:     1,
+			expectedMax:     10,
+		},
+		{
+			name:            "Custom min and max",
+			minReplicaCount: &min5,
+			maxReplicaCount: &max10,
+			expectedMin:     5,
+			expectedMax:     10,
+		},
+		{
+			name:            "Zero min, default max",
+			minReplicaCount: &min0,
+			maxReplicaCount: nil,
+			expectedMin:     1, // should use default for 0 value
+			expectedMax:     100,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			so := &ScaledObject{
+				Spec: ScaledObjectSpec{
+					MinReplicaCount: test.minReplicaCount,
+					MaxReplicaCount: test.maxReplicaCount,
+				},
+			}
+
+			minReplicas := so.GetHPAMinReplicas()
+			if *minReplicas != test.expectedMin {
+				t.Errorf("Expected GetHPAMinReplicas to return %d, got %d", test.expectedMin, *minReplicas)
+			}
+
+			maxReplicas := so.GetHPAMaxReplicas()
+			if maxReplicas != test.expectedMax {
+				t.Errorf("Expected GetHPAMaxReplicas to return %d, got %d", test.expectedMax, maxReplicas)
+			}
+		})
+	}
+}
+
+// Helper function to check if a string contains a substring
+func contains(s, substr string) bool {
+	return strings.Contains(s, substr)
+}
diff --git a/tests/internals/fallback/fallback_test.go b/tests/internals/fallback/fallback_test.go
index 7c78e2f11..9aa2b45ca 100644
--- a/tests/internals/fallback/fallback_test.go
+++ b/tests/internals/fallback/fallback_test.go
@@ -196,6 +196,41 @@ spec:
       name: {{.TriggerAuthName}}
 `
 
+	scaledObjectTemplateWithoutMetricType = `
+apiVersion: keda.sh/v1alpha1
+kind: ScaledObject
+metadata:
+  name: {{.ScaledObject}}
+  namespace: {{.Namespace}}
+  labels:
+    app: {{.DeploymentName}}
+spec:
+  scaleTargetRef:
+    name: {{.DeploymentName}}
+  minReplicaCount: {{.MinReplicas}}
+  maxReplicaCount: {{.MaxReplicas}}
+  fallback:
+    failureThreshold: 1
+    replicas: {{.DefaultFallback}}
+    behavior: currentReplicasIfHigher
+  advanced:
+    horizontalPodAutoscalerConfig:
+      behavior:
+        scaleDown:
+          stabilizationWindowSeconds: 1
+  cooldownPeriod: 1
+  pollingInterval: 5
+  triggers:
+  - type: metrics-api
+    metadata:
+      targetValue: "5"
+      url: "{{.MetricsServerEndpoint}}"
+      valueLocation: 'value'
+      method: "query"
+    authenticationRef:
+      name: {{.TriggerAuthName}}
+`
+
 	scaledObjectTemplateWithCurrentReplicasIfHigher = `
 apiVersion: keda.sh/v1alpha1
 kind: ScaledObject
@@ -394,6 +429,45 @@ func TestFallback(t *testing.T) {
 	DeleteKubernetesResources(t, namespace, data, templates)
 }
 
+func TestFallbackWithScaledObjectWithoutMetricType(t *testing.T) {
+	// setup
+	t.Log("--- setting up ScaledObjectWithoutMetricType test ---")
+	kc := GetKubernetesClient(t)
+	data, templates := getTemplateData()
+
+	// Replace the default scaledObject template
+	for i, tmpl := range templates {
+		if tmpl.Name == "scaledObjectTemplate" {
+			templates[i].Config = scaledObjectTemplateWithoutMetricType
+			break
+		}
+	}
+
+	CreateKubernetesResources(t, kc, namespace, data, templates)
+
+	assert.True(t, WaitForDeploymentReplicaReadyCount(t, kc, deploymentName, namespace, minReplicas, 180, 3),
+		"replica count should be %d after 3 minutes", minReplicas)
+
+	// Scale out to 4 replicas (20 / 5 = 4)
+	data.MetricValue = 20
+	KubectlReplaceWithTemplate(t, data, "updateMetricsTemplate", updateMetricsTemplate)
+
+	assert.True(t, WaitForDeploymentReplicaReadyCount(t, kc, deploymentName, namespace, 4, 60, 3),
+		"replica count should be 4 after 3 minutes")
+
+	// Stop metrics server to trigger fallback
+	KubectlApplyWithTemplate(t, data, "fallbackMSDeploymentTemplate", fallbackMSDeploymentTemplate)
+
+	// Should keep 4 replicas as it's higher than fallback value (3)
+	assert.True(t, WaitForDeploymentReplicaReadyCount(t, kc, deploymentName, namespace, 4, 30, 3),
+		"replica count should remain at 4 after fallback")
+
+	// Ensure the replica count remains stable
+	AssertReplicaCountNotChangeDuringTimePeriod(t, kc, deploymentName, namespace, 4, 30)
+
+	DeleteKubernetesResources(t, namespace, data, templates)
+}
+
 func TestFallbackWithCurrentReplicasIfHigher(t *testing.T) {
 	// setup
 	t.Log("--- setting up CurrentReplicasIfHigher test ---")