Repository: knative/eventing (2025-06-25T15:51:14Z)

Issue:
RequestReply: Create Data Plane
<!-- 
Are you using Knative? If you do, we would love to know!
https://github.com/knative/community/issues/new?template=ADOPTERS.yaml&title=%5BADOPTERS%5D%3A+%24%7BCOMPANY+NAME+HERE%7D
-->

**Problem**
The RequestReply resource needs a data plane which:
1. Receives events
2. If the events has the `replyAttribute` set, splits the attribute and checks if the RequestReply resource has a matching event it should reply to, if it does -> send the event as a reply to that http request.
3. If the event does not have the `replyAttribute` set, add the `id` and signed `id` as the `correlationId` event attribute, and forward the event to the RequestReply's resolved `brokerRef` url
4. After the `timeout` for a request, reply with HTTP 408 Request Timeout if the event was not already replied to.

**[Persona:](https://github.com/knative/eventing/blob/main/docs/personas.md)**
Which persona is this feature for?

**Exit Criteria**
Unit tests of the data plane forwarding events to a test http server, and replying correctly. Unit tests of the data plane replying with 408 when the timeout occurs.

**Time Estimate (optional):**
How many developer-days do you think this may take to resolve? 2-3

**Additional context (optional)**
Add any other context about the feature request here.
- https://github.com/knative/eventing/issues/7912
- https://github.com/keventmesh/llm-tool-provider/blob/main/core/request-proxy/main.go



Discussion:
/assign
/triage accepted



Patch:
diff --git a/pkg/apis/eventing/v1alpha1/register.go b/pkg/apis/eventing/v1alpha1/register.go
index e33ee9f6d94..20d52b0e8df 100644
--- a/pkg/apis/eventing/v1alpha1/register.go
+++ b/pkg/apis/eventing/v1alpha1/register.go
@@ -24,6 +24,22 @@ import (
 	"k8s.io/apimachinery/pkg/runtime/schema"
 )
 
+const (
+	// RequestReplyBrokerAddressStatusAnnotationKey is the RequestReply status
+	// annotation key used to specify the address of the broker
+	RequestReplyBrokerAddressStatusAnnotationKey = "knative.dev/brokerAddress"
+
+	// RequestReplyBrokerCACertsStatusAnnotationKey is the RequestReply status
+	// annotation key used to specify the broker's Certification Authority (CA)
+	// certificates in PEM format according to
+	// https://www.rfc-editor.org/rfc/rfc7468
+	RequestReplyBrokerCACertsStatusAnnotationKey = "knative.dev/brokerCACerts"
+
+	// RequestReplyBrokerAudienceStatusAnnotationKey is the RequestReply status
+	// anotation key used to specify the broker's OIDC audience
+	RequestReplyBrokerAudienceStatusAnnotationKey = "knative.dev/brokerAudience"
+)
+
 // SchemeGroupVersion is group version used to register these objects
 var SchemeGroupVersion = schema.GroupVersion{Group: eventing.GroupName, Version: "v1alpha1"}
 
diff --git a/pkg/apis/eventing/v1alpha1/requestreply_types.go b/pkg/apis/eventing/v1alpha1/requestreply_types.go
index 3cb37e11b89..d3015fbae7c 100644
--- a/pkg/apis/eventing/v1alpha1/requestreply_types.go
+++ b/pkg/apis/eventing/v1alpha1/requestreply_types.go
@@ -20,6 +20,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
 
 	"knative.dev/pkg/apis"
 	duckv1 "knative.dev/pkg/apis/duck/v1"
@@ -120,3 +121,10 @@ func (rr *RequestReply) GetUntypedSpec() interface{} {
 func (rr *RequestReply) GetStatus() *duckv1.Status {
 	return &rr.Status.Status
 }
+
+func (rr *RequestReply) GetNamespacedName() types.NamespacedName {
+	return types.NamespacedName{
+		Name:      rr.Name,
+		Namespace: rr.Namespace,
+	}
+}
diff --git a/pkg/requestreply/ingress_handler.go b/pkg/requestreply/ingress_handler.go
new file mode 100644
index 00000000000..8a161ac89a7
--- /dev/null
+++ b/pkg/requestreply/ingress_handler.go
@@ -0,0 +1,425 @@
+/*
+Copyright 2025 The Knative 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 requestreply
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	cloudevents "github.com/cloudevents/sdk-go/v2"
+	"github.com/cloudevents/sdk-go/v2/binding"
+	cehttp "github.com/cloudevents/sdk-go/v2/protocol/http"
+	"go.uber.org/zap"
+	"k8s.io/apimachinery/pkg/types"
+	corev1listers "k8s.io/client-go/listers/core/v1"
+
+	"knative.dev/eventing/pkg/apis/eventing/v1alpha1"
+	eventingv1alpha1informers "knative.dev/eventing/pkg/client/informers/externalversions/eventing/v1alpha1"
+	eventingv1alpha1listers "knative.dev/eventing/pkg/client/listers/eventing/v1alpha1"
+	"knative.dev/eventing/pkg/eventingtls"
+	"knative.dev/eventing/pkg/kncloudevents"
+	"knative.dev/eventing/pkg/utils"
+	"knative.dev/pkg/apis"
+	duckv1 "knative.dev/pkg/apis/duck/v1"
+)
+
+const (
+	defaultMaxIdleConnections        = 1000
+	defaultMaxIdleConnectionsPerHost = 1000
+)
+
+type IngressHandler struct {
+	dispatcher         *kncloudevents.Dispatcher
+	logger             *zap.Logger
+	requestReplyLister eventingv1alpha1listers.RequestReplyLister
+	keyStore           AESKeyStore
+
+	requestLock sync.RWMutex
+	entries     map[types.NamespacedName]map[string]*proxiedRequest
+}
+
+type proxiedRequest struct {
+	received       time.Time
+	responseWriter http.ResponseWriter
+	replyEvent     chan *cloudevents.Event
+}
+
+func NewHandler(logger *zap.Logger, requestReplyInformer eventingv1alpha1informers.RequestReplyInformer, trustBundleConfigMapLister corev1listers.ConfigMapNamespaceLister, keyStore AESKeyStore) *IngressHandler {
+	connectionArgs := kncloudevents.ConnectionArgs{
+		MaxIdleConns:        defaultMaxIdleConnections,
+		MaxIdleConnsPerHost: defaultMaxIdleConnectionsPerHost,
+	}
+
+	kncloudevents.ConfigureConnectionArgs(&connectionArgs)
+
+	clientConfig := eventingtls.ClientConfig{
+		TrustBundleConfigMapLister: trustBundleConfigMapLister,
+	}
+
+	return &IngressHandler{
+		logger:             logger,
+		dispatcher:         kncloudevents.NewDispatcher(clientConfig, nil),
+		requestReplyLister: requestReplyInformer.Lister(),
+		keyStore:           keyStore,
+
+		entries: make(map[types.NamespacedName]map[string]*proxiedRequest),
+	}
+
+}
+
+func (h *IngressHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	w.Header().Set("Allow", "POST, OPTIONS")
+	// validate request method
+	if req.Method == http.MethodOptions {
+		w.Header().Set("WebHook-Allowed-Origin", "*") // Accept from any Origin
+		w.Header().Set("WebHook-Allowed-Rate", "*")   // Unlimited requests/minute
+		w.WriteHeader(http.StatusOK)
+		return
+	}
+	if req.Method != http.MethodPost {
+		h.logger.Warn("unexpected request method", zap.String("method", req.Method))
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		return
+	}
+
+	// validate request URI
+	if req.RequestURI == "/" {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+	nsRequestReplyName := strings.Split(strings.TrimSuffix(req.RequestURI, "/"), "/")
+	if len(nsRequestReplyName) != 3 {
+		h.logger.Info("Malformed uri", zap.String("uri", req.RequestURI))
+		w.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	// extract event from request
+	message := cehttp.NewMessageFromHttpRequest(req)
+	defer message.Finish(nil)
+
+	ctx := context.Background()
+
+	event, err := binding.ToEvent(ctx, message)
+	if err != nil {
+		h.logger.Warn("failed to extract event from request", zap.Error(err))
+		w.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	// validate the extracted event
+	if validationErr := event.Validate(); validationErr != nil {
+		h.logger.Warn("failed to validate extracted event", zap.Error(validationErr))
+		w.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	requestReplyNs, requestReplyName := nsRequestReplyName[1], nsRequestReplyName[2]
+	requestReply, err := h.getRequestReply(requestReplyName, requestReplyNs)
+	if err != nil {
+		h.logger.Warn("failed to retrieve RequestReply", zap.Error(err))
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Minute) // TODO: make this timeout configurable
+	defer cancel()
+
+	if isResponseEvent(event, requestReply) {
+		h.handleResponseEvent(w, event, requestReply)
+	} else {
+		h.handleNewEvent(ctx, w, event, requestReply, utils.PassThroughHeaders(req.Header))
+	}
+}
+
+func (h *IngressHandler) getRequestReply(name, namespace string) (*v1alpha1.RequestReply, error) {
+	rr, err := h.requestReplyLister.RequestReplies(namespace).Get(name)
+	if err != nil {
+		h.logger.Warn("RequestReply getter failed")
+		return nil, err
+	}
+	return rr, nil
+}
+
+func (h *IngressHandler) getBrokerAddress(rr *v1alpha1.RequestReply) (*duckv1.Addressable, error) {
+	if rr.Status.Annotations == nil {
+		return nil, fmt.Errorf("RequestReply status annotations uninitialized")
+	}
+
+	address, ok := rr.Status.Annotations[v1alpha1.RequestReplyBrokerAddressStatusAnnotationKey]
+	if !ok {
+		return nil, fmt.Errorf("broker address not found in RequestReply status annotations")
+	}
+
+	url, err := apis.ParseURL(address)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse broker address url")
+	}
+
+	addr := &duckv1.Addressable{
+		URL: url,
+	}
+
+	if certs, ok := rr.Status.Annotations[v1alpha1.RequestReplyBrokerCACertsStatusAnnotationKey]; ok && certs != "" {
+		addr.CACerts = &certs
+	}
+
+	if audience, ok := rr.Status.Annotations[v1alpha1.RequestReplyBrokerAudienceStatusAnnotationKey]; ok && audience != "" {
+		addr.Audience = &audience
+	}
+
+	return addr, nil
+}
+
+func (h *IngressHandler) addEvent(responseWriter http.ResponseWriter, event *cloudevents.Event, rr *v1alpha1.RequestReply) *proxiedRequest {
+	h.requestLock.Lock()
+	defer h.requestLock.Unlock()
+
+	id := event.ID()
+	pr := &proxiedRequest{
+		received:       time.Now(),
+		responseWriter: responseWriter,
+		replyEvent:     make(chan *cloudevents.Event, 1),
+	}
+	if h.entries[rr.GetNamespacedName()] == nil {
+		h.entries[rr.GetNamespacedName()] = make(map[string]*proxiedRequest)
+	}
+
+	h.entries[rr.GetNamespacedName()][id] = pr
+
+	return pr
+}
+
+func (h *IngressHandler) deleteEvent(event *cloudevents.Event, rr *v1alpha1.RequestReply) {
+	h.requestLock.Lock()
+	defer h.requestLock.Unlock()
+	delete(h.entries[rr.GetNamespacedName()], event.ID())
+}
+
+func (h *IngressHandler) handleNewEvent(ctx context.Context, responseWriter http.ResponseWriter, event *cloudevents.Event, rr *v1alpha1.RequestReply, headers http.Header) {
+	h.logger.Debug("handling new event")
+	pr := h.addEvent(responseWriter, event, rr)
+
+	brokerAddress, err := h.getBrokerAddress(rr)
+	if err != nil {
+		h.logger.Warn("no broker address annotation set in the request reply resource status", zap.String("name", rr.GetName()), zap.String("namespace", rr.GetNamespace()))
+		responseWriter.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	latestKey, ok := h.keyStore.GetLatestKey(rr.GetNamespacedName())
+	if !ok {
+		h.logger.Warn("no aes key found for requestreply resource", zap.String("name", rr.GetName()), zap.String("namespace", rr.GetNamespace()))
+		responseWriter.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	err = SetCorrelationId(event, rr.Spec.CorrelationAttribute, latestKey)
+	if err != nil {
+		h.logger.Error("failed to set correlation id on event", zap.Error(err))
+		responseWriter.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	opts := []kncloudevents.SendOption{
+		kncloudevents.WithHeader(headers),
+		// TODO: add oidc stuff here
+	}
+
+	_, err = h.dispatcher.SendEvent(ctx, *event, *brokerAddress, opts...)
+	if err != nil {
+		h.logger.Error("failed to dispatch event", zap.Error(err))
+		responseWriter.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	for {
+		select {
+		case resp := <-pr.replyEvent:
+			msg := binding.ToMessage(resp)
+			err := cehttp.WriteResponseWriter(ctx, msg, http.StatusOK, pr.responseWriter)
+			if err != nil {
+				h.logger.Error("failed to send event back", zap.Error(err))
+			}
+			msg.Finish(err)
+			h.deleteEvent(event, rr)
+			return
+		case <-ctx.Done():
+			h.logger.Warn("context timeout reached before encountering a response to the event, discarding event", zap.String("event id", event.ID()))
+			h.deleteEvent(event, rr)
+			return
+		}
+	}
+}
+func (h *IngressHandler) handleResponseEvent(responseWriter http.ResponseWriter, event *cloudevents.Event, rr *v1alpha1.RequestReply) {
+	h.requestLock.RLock()
+	defer h.requestLock.RUnlock()
+
+	h.logger.Debug("handling a response event")
+
+	// TODO: with OIDC enabled, we can skip validation of the key if we validate the identity of the trigger making the request
+	// This can be handled in a future PR to add the OIDC support for the RequestReply resource
+	allKeys, ok := h.keyStore.GetAllKeys(rr.GetNamespacedName())
+	if !ok {
+		h.logger.Warn("no aes keys found for requestreply resource", zap.String("name", rr.GetName()), zap.String("namespace", rr.GetNamespace()))
+		responseWriter.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	replyId, ok := event.Extensions()[rr.Spec.ReplyAttribute]
+	if !ok {
+		h.logger.Warn("no reply id set on the event")
+		responseWriter.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	replyIdString, ok := replyId.(string)
+	if !ok {
+		h.logger.Warn("failed to convert the replyid to a string")
+		responseWriter.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	validReply := false
+	for _, key := range allKeys {
+		valid, err := VerifyReplyId(replyIdString, key)
+		if err != nil {
+			h.logger.Warn("ran into an error validating replyid", zap.Error(err))
+			continue
+		}
+		if valid {
+			validReply = true
+			break
+		}
+	}
+
+	if !validReply {
+		h.logger.Warn("received invalid reply event")
+		responseWriter.WriteHeader(http.StatusBadRequest)
+		return
+	}
+
+	responseWriter.WriteHeader(http.StatusAccepted)
+
+	id := strings.Split(replyIdString, ":")[0]
+	pr, ok := h.entries[rr.GetNamespacedName()][id]
+	if !ok {
+		h.logger.Warn("no event found matching the reply id, discarding event", zap.String("reply id", id))
+		return
+	}
+
+	// send the reply event back to the original response writer
+	pr.replyEvent <- event
+}
+
+type requestReplyAesKeyStore struct {
+	lock      sync.RWMutex
+	newestKey []byte
+	entries   map[string][]byte
+}
+
+func (ks *requestReplyAesKeyStore) addAesKey(keyName string, keyValue []byte) {
+	ks.lock.Lock()
+	defer ks.lock.Unlock()
+
+	ks.newestKey = keyValue
+
+	if ks.entries == nil {
+		ks.entries = map[string][]byte{keyName: keyValue}
+		return
+	}
+
+	ks.entries[keyName] = keyValue
+}
+
+func (ks *requestReplyAesKeyStore) removeAesKey(keyName string) {
+	ks.lock.Lock()
+	defer ks.lock.Unlock()
+
+	delete(ks.entries, keyName)
+}
+
+func (ks *requestReplyAesKeyStore) getLatestKey() []byte {
+	ks.lock.RLock()
+	defer ks.lock.RUnlock()
+
+	return ks.newestKey
+}
+
+func (ks *requestReplyAesKeyStore) getAllKeys() [][]byte {
+	ks.lock.RLock()
+	defer ks.lock.RUnlock()
+
+	keys := make([][]byte, len(ks.entries))
+
+	i := 0
+	for _, v := range ks.entries {
+		keys[i] = v
+		i++
+	}
+
+	return keys
+}
+
+type AESKeyStore struct {
+	keyStores map[types.NamespacedName]*requestReplyAesKeyStore
+}
+
+func (ks *AESKeyStore) AddAesKey(rrName types.NamespacedName, keyName string, keyValue []byte) {
+	if ks.keyStores == nil {
+		ks.keyStores = make(map[types.NamespacedName]*requestReplyAesKeyStore)
+	}
+
+	if ks.keyStores[rrName] == nil {
+		ks.keyStores[rrName] = &requestReplyAesKeyStore{}
+	}
+
+	ks.keyStores[rrName].addAesKey(keyName, keyValue)
+}
+
+func (ks *AESKeyStore) RemoveAesKey(rrName types.NamespacedName, keyName string) {
+	if ks.keyStores[rrName] == nil {
+		return
+	}
+
+	ks.keyStores[rrName].removeAesKey(keyName)
+}
+
+func (ks *AESKeyStore) GetLatestKey(rrName types.NamespacedName) ([]byte, bool) {
+	if ks.keyStores[rrName] == nil {
+		return nil, false
+	}
+
+	return ks.keyStores[rrName].getLatestKey(), true
+}
+
+func (ks *AESKeyStore) GetAllKeys(rrName types.NamespacedName) ([][]byte, bool) {
+	if ks.keyStores[rrName] == nil {
+		return nil, false
+	}
+
+	return ks.keyStores[rrName].getAllKeys(), true
+}
+
+func isResponseEvent(event *cloudevents.Event, rr *v1alpha1.RequestReply) bool {
+	_, ok := event.Extensions()[rr.Spec.ReplyAttribute]
+	return ok
+}


Test patch:
diff --git a/pkg/requestreply/ingress_handler_test.go b/pkg/requestreply/ingress_handler_test.go
new file mode 100644
index 00000000000..d6b29c956f9
--- /dev/null
+++ b/pkg/requestreply/ingress_handler_test.go
@@ -0,0 +1,325 @@
+/*
+Copyright 2025 The Knative 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 requestreply
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	cloudevents "github.com/cloudevents/sdk-go/v2"
+	cehttp "github.com/cloudevents/sdk-go/v2/protocol/http"
+	"github.com/stretchr/testify/assert"
+	"go.uber.org/zap"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	"knative.dev/eventing/pkg/apis/eventing/v1alpha1"
+	requestreplyinformerfake "knative.dev/eventing/pkg/client/injection/informers/eventing/v1alpha1/requestreply/fake"
+	"knative.dev/eventing/pkg/eventingtls"
+	duckv1 "knative.dev/pkg/apis/duck/v1"
+	configmapinformerfake "knative.dev/pkg/client/injection/kube/informers/core/v1/configmap/fake"
+	filteredFactory "knative.dev/pkg/client/injection/kube/informers/factory/filtered"
+	reconcilertesting "knative.dev/pkg/reconciler/testing"
+)
+
+func TestHandlerServeHttp(t *testing.T) {
+	t.Parallel()
+
+	logger := zap.NewNop()
+
+	tt := map[string]struct {
+		method          string
+		uri             string
+		body            io.Reader
+		headers         http.Header
+		expectedHeaders http.Header
+		statusCode      int
+		requestReplys   []*v1alpha1.RequestReply
+		makeReplyEvent  func(e *cloudevents.Event) *cloudevents.Event
+		keys            map[types.NamespacedName]map[string][]byte
+	}{
+		"invalid method PATCH": {
+			method:     http.MethodPatch,
+			uri:        "/ns/name",
+			body:       getValidEvent(),
+			statusCode: http.StatusMethodNotAllowed,
+		},
+		"invalid method PUT": {
+			method:     http.MethodPut,
+			uri:        "/ns/name",
+			body:       getValidEvent(),
+			statusCode: http.StatusMethodNotAllowed,
+		},
+		"invalid method DELETE": {
+			method:     http.MethodDelete,
+			uri:        "/ns/name",
+			body:       getValidEvent(),
+			statusCode: http.StatusMethodNotAllowed,
+		},
+		"invalid method GET": {
+			method:     http.MethodGet,
+			uri:        "/ns/name",
+			body:       getValidEvent(),
+			statusCode: http.StatusMethodNotAllowed,
+		},
+		"valid method OPTIONS": {
+			method:     http.MethodOptions,
+			uri:        "/ns/name",
+			body:       strings.NewReader(""),
+			statusCode: http.StatusOK,
+		},
+		"valid event and reply": {
+			method:     http.MethodPost,
+			uri:        "/default/my-request-reply",
+			body:       getValidEvent(),
+			statusCode: http.StatusOK,
+			requestReplys: []*v1alpha1.RequestReply{
+				makeRequestReply("my-request-reply", "default"),
+			},
+			keys: makeKeysMap(
+				addKeysForResource(
+					makeRequestReply("my-request-reply", "default"),
+					addKey("key", exampleKey),
+				),
+			),
+			makeReplyEvent: copyCorrelationIdToReplyID("correlationid", "replyid"),
+		},
+		"valid event and reply, multiple RequestReply resources with different keys": {
+			method:     http.MethodPost,
+			uri:        "/default/my-request-reply",
+			body:       getValidEvent(),
+			statusCode: http.StatusOK,
+			requestReplys: []*v1alpha1.RequestReply{
+				makeRequestReply("my-request-reply", "default"),
+				makeRequestReply("other-request-reply", "default"),
+			},
+			keys: makeKeysMap(
+				addKeysForResource(
+					makeRequestReply("my-request-reply", "default"),
+					addKey("key", exampleKey),
+				),
+				addKeysForResource(
+					makeRequestReply("other-request-reply", "default"),
+					addKey("key", otherKey),
+				),
+			),
+			makeReplyEvent: copyCorrelationIdToReplyID("correlationid", "replyid"),
+		},
+		"missing RequestReply": {
+			method:     http.MethodPost,
+			uri:        "/default/missing-request-reply",
+			body:       getValidEvent(),
+			statusCode: http.StatusNotFound,
+			requestReplys: []*v1alpha1.RequestReply{
+				makeRequestReply("my-request-reply", "default"),
+			},
+			keys: makeKeysMap(
+				addKeysForResource(
+					makeRequestReply("my-request-reply", "default"),
+					addKey("key", exampleKey),
+				),
+			),
+		},
+		"missing broker address status annotation": {
+			method:     http.MethodPost,
+			uri:        "/default/my-request-reply",
+			body:       getValidEvent(),
+			statusCode: http.StatusInternalServerError,
+			requestReplys: []*v1alpha1.RequestReply{
+				withUninitializedAnnotations(makeRequestReply("my-request-reply", "default")),
+			},
+			keys: makeKeysMap(
+				addKeysForResource(
+					makeRequestReply("my-request-reply", "default"),
+					addKey("key", exampleKey),
+				),
+			),
+		},
+	}
+
+	for testName, testCase := range tt {
+		t.Run(testName, func(t *testing.T) {
+			ctx, _ := reconcilertesting.SetupFakeContext(t, setupInformerSelector)
+
+			testHandler := &testServerHandler{
+				makeReplyEvent: testCase.makeReplyEvent,
+				t:              t,
+				uri:            testCase.uri,
+			}
+
+			s := httptest.NewServer(testHandler)
+			defer s.Close()
+
+			recorder := httptest.NewRecorder()
+			request := httptest.NewRequest(testCase.method, testCase.uri, testCase.body)
+
+			if testCase.headers != nil {
+				request.Header = testCase.headers
+			} else {
+				request.Header.Add(cehttp.ContentType, cloudevents.ApplicationCloudEventsJSON)
+			}
+
+			for _, rr := range testCase.requestReplys {
+				if rr.Status.Annotations != nil {
+					if _, set := rr.Status.Annotations[v1alpha1.RequestReplyBrokerAddressStatusAnnotationKey]; !set {
+						rr.Status.Annotations = map[string]string{
+							v1alpha1.RequestReplyBrokerAddressStatusAnnotationKey: s.URL,
+						}
+					}
+				}
+
+				requestreplyinformerfake.Get(ctx).Informer().GetStore().Add(rr)
+			}
+
+			keyStore := AESKeyStore{}
+
+			for rrName, keyMap := range testCase.keys {
+				for keyName, keyValue := range keyMap {
+					keyStore.AddAesKey(rrName, keyName, keyValue)
+				}
+			}
+
+			handler := NewHandler(logger, requestreplyinformerfake.Get(ctx), configmapinformerfake.Get(ctx).Lister().ConfigMaps("ns"), keyStore)
+
+			testHandler.callbackHandler = handler
+
+			handler.ServeHTTP(recorder, request)
+
+			result := recorder.Result()
+			assert.Equal(t, testCase.statusCode, result.StatusCode)
+		})
+	}
+}
+
+type testServerHandler struct {
+	makeReplyEvent  func(e *cloudevents.Event) *cloudevents.Event
+	callbackHandler http.Handler
+	uri             string
+	t               *testing.T
+}
+
+func (ts *testServerHandler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
+	// decode incoming event
+	event, err := cloudevents.NewEventFromHTTPRequest(request)
+	assert.NoError(ts.t, err, "should successfully decode event from forwarded request")
+
+	// make a reply event
+	replyEvent := ts.makeReplyEvent(event)
+
+	// send the reply event to the server as a new request (simulating how the RequestReply works)
+	replyRequest, _ := cloudevents.NewHTTPRequestFromEvent(context.Background(), ts.uri, *replyEvent)
+	// the SDK does not seem to set the request uri, so we set it manually here
+	replyRequest.RequestURI = ts.uri
+	recorder := httptest.NewRecorder()
+	ts.callbackHandler.ServeHTTP(recorder, replyRequest)
+}
+
+func setupInformerSelector(ctx context.Context) context.Context {
+	ctx = filteredFactory.WithSelectors(ctx, eventingtls.TrustBundleLabelSelector)
+	return ctx
+}
+
+func makeRequestReply(name, namespace string, opts ...func(rr *v1alpha1.RequestReply)) *v1alpha1.RequestReply {
+	rr := &v1alpha1.RequestReply{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "eventing.knative.dev/v1alpha1",
+			Kind:       "RequestReply",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Spec: v1alpha1.RequestReplySpec{
+			ReplyAttribute:       "replyid",
+			CorrelationAttribute: "correlationid",
+		},
+		Status: v1alpha1.RequestReplyStatus{
+			Status: duckv1.Status{
+				Annotations: map[string]string{},
+			},
+		},
+	}
+
+	for _, opt := range opts {
+		opt(rr)
+	}
+
+	return rr
+}
+
+func makeKeysMap(resourceFuncs ...func(map[types.NamespacedName]map[string][]byte)) map[types.NamespacedName]map[string][]byte {
+	result := make(map[types.NamespacedName]map[string][]byte)
+
+	for _, f := range resourceFuncs {
+		f(result)
+	}
+
+	return result
+}
+
+func addKeysForResource(rr *v1alpha1.RequestReply, keyFuncs ...func(map[string][]byte) map[string][]byte) func(map[types.NamespacedName]map[string][]byte) {
+	return func(m map[types.NamespacedName]map[string][]byte) {
+		if m == nil {
+			m = make(map[types.NamespacedName]map[string][]byte)
+		}
+
+		if m[rr.GetNamespacedName()] == nil {
+			m[rr.GetNamespacedName()] = make(map[string][]byte)
+		}
+
+		for _, f := range keyFuncs {
+			m[rr.GetNamespacedName()] = f(m[rr.GetNamespacedName()])
+		}
+	}
+}
+
+func addKey(keyName string, keyValue []byte) func(map[string][]byte) map[string][]byte {
+	return func(m map[string][]byte) map[string][]byte {
+		if m == nil {
+			m = make(map[string][]byte)
+		}
+		m[keyName] = keyValue
+		return m
+	}
+}
+
+func copyCorrelationIdToReplyID(correlationIdName, replyIdName string) func(e *cloudevents.Event) *cloudevents.Event {
+	return func(e *cloudevents.Event) *cloudevents.Event {
+		event := e.Clone()
+		event.SetExtension(replyIdName, event.Extensions()[correlationIdName])
+		return &event
+	}
+}
+
+func getValidEvent() io.Reader {
+	e := cloudevents.NewEvent()
+	e.SetType("type")
+	e.SetSource("source")
+	e.SetID("1234567890")
+	b, _ := e.MarshalJSON()
+	return bytes.NewBuffer(b)
+}
+
+func withUninitializedAnnotations(rr *v1alpha1.RequestReply) *v1alpha1.RequestReply {
+	rr.Status.Annotations = nil
+	return rr
+}