Repository: envoyproxy/ai-gateway
Pull request #399 (2025-02-21 12:56:18)

extproc: return HTTP 404 when no matching route
**Commit Message**

extproc: return HTTP 404 when no matching route
in `AIGatewayRoute` instead of a 500 status code.

## Before

```console
$ curl \
    -H "Content-Type: application/json" \
    -d '{
    "model": "DEADBEEF",
    "messages": [
        {
            "role": "user",
            "content": "Who should be king of the world? Be direct, decisive, and opinionated."
        }
    ]
}' $GATEWAY_URL/v1/chat/completions -i
HTTP/1.1 500 Not Found
content-length: 22
content-type: text/plain
date: Fri, 21 Feb 2025 12:51:06 GMT
```

## After

```console
$ curl \
    -H "Content-Type: application/json" \
    -d '{
    "model": "DEADBEEF",
    "messages": [
        {
            "role": "user",
            "content": "Who should be king of the world? Be direct, decisive, and opinionated."
        }
    ]
}' $GATEWAY_URL/v1/chat/completions -i
HTTP/1.1 404 Not Found
content-length: 22
content-type: text/plain
date: Fri, 21 Feb 2025 12:51:06 GMT

no matching rule found
```

mathetake: 💯💯💯💯💯 

Patch:
diff --git a/internal/extproc/chatcompletion_processor.go b/internal/extproc/chatcompletion_processor.go
index 56f0911..6cbfaf0 100644
--- a/internal/extproc/chatcompletion_processor.go
+++ b/internal/extproc/chatcompletion_processor.go
@@ -10,16 +10,19 @@ import (
 	"compress/gzip"
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"log/slog"
 
 	corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
 	extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
+	typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
 	"google.golang.org/protobuf/types/known/structpb"
 
 	"github.com/envoyproxy/ai-gateway/filterapi"
 	"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
+	"github.com/envoyproxy/ai-gateway/internal/extproc/router"
 	"github.com/envoyproxy/ai-gateway/internal/extproc/translator"
 	"github.com/envoyproxy/ai-gateway/internal/llmcostcel"
 )
@@ -84,6 +87,17 @@ func (c *chatCompletionProcessor) ProcessRequestBody(ctx context.Context, rawBod
 	c.requestHeaders[c.config.modelNameHeaderKey] = model
 	b, err := c.config.router.Calculate(c.requestHeaders)
 	if err != nil {
+		if errors.Is(err, router.ErrNoMatchingRule) {
+			return &extprocv3.ProcessingResponse{
+				Response: &extprocv3.ProcessingResponse_ImmediateResponse{
+					ImmediateResponse: &extprocv3.ImmediateResponse{
+						Status: &typev3.HttpStatus{Code: typev3.StatusCode_NotFound},
+						Body:   []byte(err.Error()),
+					},
+				},
+			}, nil
+		}
+
 		return nil, fmt.Errorf("failed to calculate route: %w", err)
 	}
 	c.logger.Info("Selected backend", "backend", b.Name)
diff --git a/internal/extproc/router/router.go b/internal/extproc/router/router.go
index d0507cf..759663e 100644
--- a/internal/extproc/router/router.go
+++ b/internal/extproc/router/router.go
@@ -15,6 +15,9 @@ import (
 	"github.com/envoyproxy/ai-gateway/filterapi/x"
 )
 
+// ErrNoMatchingRule is the error when no matching rule is found.
+var ErrNoMatchingRule = errors.New("no matching rule found")
+
 // router implements [x.Router].
 type router struct {
 	rules []filterapi.RouteRule
@@ -46,7 +49,7 @@ func (r *router) Calculate(headers map[string]string) (backend *filterapi.Backen
 		}
 	}
 	if rule == nil {
-		return nil, errors.New("no matching rule found")
+		return nil, ErrNoMatchingRule
 	}
 	return r.selectBackendFromRule(rule), nil
 }


Test patch:
diff --git a/internal/extproc/chatcompletion_processor_test.go b/internal/extproc/chatcompletion_processor_test.go
index 649877a..4c035d5 100644
--- a/internal/extproc/chatcompletion_processor_test.go
+++ b/internal/extproc/chatcompletion_processor_test.go
@@ -14,10 +14,12 @@ import (
 
 	corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
 	extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
+	typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
 	"github.com/stretchr/testify/require"
 
 	"github.com/envoyproxy/ai-gateway/filterapi"
 	"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
+	"github.com/envoyproxy/ai-gateway/internal/extproc/router"
 	"github.com/envoyproxy/ai-gateway/internal/extproc/translator"
 	"github.com/envoyproxy/ai-gateway/internal/llmcostcel"
 )
@@ -161,6 +163,18 @@ func TestChatCompletion_ProcessRequestBody(t *testing.T) {
 		_, err := p.ProcessRequestBody(t.Context(), &extprocv3.HttpBody{Body: bodyFromModel(t, "some-model")})
 		require.ErrorContains(t, err, "failed to calculate route: test error")
 	})
+	t.Run("router error 404", func(t *testing.T) {
+		headers := map[string]string{":path": "/foo"}
+		rt := mockRouter{t: t, expHeaders: headers, retErr: router.ErrNoMatchingRule}
+		p := &chatCompletionProcessor{config: &processorConfig{router: rt}, requestHeaders: headers, logger: slog.Default()}
+		resp, err := p.ProcessRequestBody(t.Context(), &extprocv3.HttpBody{Body: bodyFromModel(t, "some-model")})
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		ir := resp.GetImmediateResponse()
+		require.NotNil(t, ir)
+		require.Equal(t, typev3.StatusCode_NotFound, ir.GetStatus().GetCode())
+		require.Equal(t, router.ErrNoMatchingRule.Error(), string(ir.GetBody()))
+	})
 	t.Run("translator not found", func(t *testing.T) {
 		headers := map[string]string{":path": "/foo"}
 		rt := mockRouter{