Repository: cloudfoundry/gorouter
Pull request #467: Add routing via X-CF-PROCESS-INSTANCE
Author: login: ameowlia
Created: 2025-03-17T21:01:48Z | Merged: 2025-03-18T19:31:57Z

- [X] Read the [Contributing document](../blob/-/.github/CONTRIBUTING.md).

Summary
---------------
When users do a [canary deploy](https://docs.cloudfoundry.org/devguide/deploy-apps/rolling-deploy.html#canary-deployment-process) there is no easy way to send traffic to the canary instance to make sure it is working properly.

This new header will allow users to route via process_guid and via process_guid + index.

The new header must be provided in the form...
```
X-CF-PROCESS-INSTANCE:GUID:INDEX
or
X-CF-PROCESS-INSTANCE:GUID
```

For example:
```
X-CF-PROCESS-INSTANCE:dfb90cf9-2af4-4ee1-a0c0-67cebd75433e:1
or
X-CF-PROCESS-INSTANCE:dfb90cf9-2af4-4ee1-a0c0-67cebd75433e
```

This was modeled after the X-CF-APP-INSTANCE header.

If an invalid guid or index is provided, then it will return a 400 and error message.


Backward Compatibility
---------------
Breaking Change? No.


Diff:
diff --git a/common/http/headers.go b/common/http/headers.go
index dc24fcbb7..63a19ca0c 100644
--- a/common/http/headers.go
+++ b/common/http/headers.go
@@ -9,6 +9,7 @@ const (
 	VcapTraceHeader       = "X-Vcap-Trace"
 	CfInstanceIdHeader    = "X-CF-InstanceID"
 	CfAppInstance         = "X-CF-APP-INSTANCE"
+	CfProcessInstance     = "X-CF-PROCESS-INSTANCE"
 	CfRouterError         = "X-Cf-RouterError"
 )
 
diff --git a/handlers/lookup.go b/handlers/lookup.go
index fba946ee0..3a098ca00 100644
--- a/handlers/lookup.go
+++ b/handlers/lookup.go
@@ -5,7 +5,6 @@ import (
 	"log/slog"
 	"net/http"
 	"regexp"
-	"strings"
 
 	"github.com/urfave/negroni/v3"
 
@@ -19,14 +18,22 @@ import (
 
 const CfAppInstance = "X-CF-APP-INSTANCE"
 
-type InvalidInstanceHeaderError struct {
+type InvalidAppInstanceHeaderError struct {
 	headerValue string
 }
 
-func (err InvalidInstanceHeaderError) Error() string {
+func (err InvalidAppInstanceHeaderError) Error() string {
 	return fmt.Sprintf("invalid-app-instance-header: %s", err.headerValue)
 }
 
+type InvalidProcessInstanceHeaderError struct {
+	headerValue string
+}
+
+func (err InvalidProcessInstanceHeaderError) Error() string {
+	return fmt.Sprintf("invalid-process-instance-header: %s", err.headerValue)
+}
+
 type lookupHandler struct {
 	registry                 registry.Registry
 	reporter                 metrics.ProxyReporter
@@ -75,8 +82,13 @@ func (l *lookupHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request, next
 	}
 
 	pool, err := l.lookup(r, logger)
-	if _, ok := err.(InvalidInstanceHeaderError); ok {
-		l.handleInvalidInstanceHeader(rw, r, logger)
+	if _, ok := err.(InvalidAppInstanceHeaderError); ok {
+		l.handleInvalidAppInstanceHeader(rw, r, logger)
+		return
+	}
+
+	if _, ok := err.(InvalidProcessInstanceHeaderError); ok {
+		l.handleInvalidProcessInstanceHeader(rw, r, logger)
 		return
 	}
 
@@ -109,7 +121,7 @@ func (l *lookupHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request, next
 	next(rw, r)
 }
 
-func (l *lookupHandler) handleInvalidInstanceHeader(rw http.ResponseWriter, r *http.Request, logger *slog.Logger) {
+func (l *lookupHandler) handleInvalidAppInstanceHeader(rw http.ResponseWriter, r *http.Request, logger *slog.Logger) {
 	l.reporter.CaptureBadRequest()
 
 	AddRouterErrorHeader(rw, "invalid_cf_app_instance_header")
@@ -123,6 +135,20 @@ func (l *lookupHandler) handleInvalidInstanceHeader(rw http.ResponseWriter, r *h
 	)
 }
 
+func (l *lookupHandler) handleInvalidProcessInstanceHeader(rw http.ResponseWriter, r *http.Request, logger *slog.Logger) {
+	l.reporter.CaptureBadRequest()
+
+	AddRouterErrorHeader(rw, "invalid_cf_process_instance_header")
+	addNoCacheControlHeader(rw)
+
+	l.errorWriter.WriteError(
+		rw,
+		http.StatusBadRequest,
+		"Invalid X-CF-Process-Instance Header",
+		logger,
+	)
+}
+
 func (l *lookupHandler) handleMissingHost(rw http.ResponseWriter, r *http.Request, logger *slog.Logger) {
 	l.reporter.CaptureBadRequest()
 
@@ -147,11 +173,23 @@ func (l *lookupHandler) handleMissingRoute(rw http.ResponseWriter, r *http.Reque
 	returnStatus := http.StatusNotFound
 
 	if appInstanceHeader := r.Header.Get(router_http.CfAppInstance); appInstanceHeader != "" {
-		guid, idx := splitInstanceHeader(appInstanceHeader)
+		// parseAppInstanceHeader had already been called. So the error has already been checked.
+		guid, idx, _ := parseAppInstanceHeader(appInstanceHeader)
 		errorMsg = fmt.Sprintf("Requested instance ('%s') with guid ('%s') does not exist for route ('%s')", idx, guid, r.Host)
 		returnStatus = http.StatusBadRequest
 	}
 
+	if processInstanceHeader := r.Header.Get(router_http.CfProcessInstance); processInstanceHeader != "" {
+		// parseProcessInstanceHeader had already been called. So the error has already been checked.
+		guid, idx, _ := parseProcessInstanceHeader(processInstanceHeader)
+		if idx == "" {
+			errorMsg = fmt.Sprintf("Requested instance with process guid ('%s') does not exist for route ('%s')", guid, r.Host)
+		} else {
+			errorMsg = fmt.Sprintf("Requested instance ('%s') with process guid ('%s') does not exist for route ('%s')", idx, guid, r.Host)
+		}
+		returnStatus = http.StatusBadRequest
+	}
+
 	l.errorWriter.WriteError(
 		rw,
 		returnStatus,
@@ -193,29 +231,47 @@ func (l *lookupHandler) lookup(r *http.Request, logger *slog.Logger) (*route.End
 	appInstanceHeader := r.Header.Get(router_http.CfAppInstance)
 
 	if appInstanceHeader != "" {
-		err := validateInstanceHeader(appInstanceHeader)
+		appID, appIndex, err := parseAppInstanceHeader(appInstanceHeader)
 		if err != nil {
 			logger.Error("invalid-app-instance-header", log.ErrAttr(err))
-			return nil, InvalidInstanceHeaderError{headerValue: appInstanceHeader}
+			return nil, InvalidAppInstanceHeaderError{headerValue: appInstanceHeader}
 		}
 
-		appID, appIndex := splitInstanceHeader(appInstanceHeader)
-		return l.registry.LookupWithInstance(uri, appID, appIndex), nil
+		return l.registry.LookupWithAppInstance(uri, appID, appIndex), nil
+	}
+
+	processInstanceHeader := r.Header.Get(router_http.CfProcessInstance)
+	if processInstanceHeader != "" {
+		processID, processIndex, err := parseProcessInstanceHeader(processInstanceHeader)
+		if err != nil {
+			logger.Error("invalid-process-instance-header", log.ErrAttr(err))
+			return nil, InvalidProcessInstanceHeaderError{headerValue: processInstanceHeader}
+		}
+
+		return l.registry.LookupWithProcessInstance(uri, processID, processIndex), nil
 	}
 
 	return l.registry.Lookup(uri), nil
 }
 
-func validateInstanceHeader(appInstanceHeader string) error {
-	// Regex to match format of `APP_GUID:INSTANCE_ID`
-	r := regexp.MustCompile(`^[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12}:\d+$`)
-	if !r.MatchString(appInstanceHeader) {
-		return fmt.Errorf("Incorrect %s header : %s", CfAppInstance, appInstanceHeader)
+// Regex to match format of `APP_GUID:INSTANCE_ID`
+var aReg = regexp.MustCompile(`^(?P<id>[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12}):(?P<idx>\d+)$`)
+
+func parseAppInstanceHeader(appInstanceHeader string) (string, string, error) {
+	matches := aReg.FindStringSubmatch(appInstanceHeader)
+	if len(matches) == 0 {
+		return "", "", fmt.Errorf("Incorrect %s header : %s", router_http.CfAppInstance, appInstanceHeader)
 	}
-	return nil
+	return matches[aReg.SubexpIndex("id")], matches[aReg.SubexpIndex("idx")], nil
 }
 
-func splitInstanceHeader(appInstanceHeader string) (string, string) {
-	appDetails := strings.Split(appInstanceHeader, ":")
-	return appDetails[0], appDetails[1]
+// Regex to match format of `PROCESS_GUID:INSTANCE_ID` and `PROCESS_GUID`
+var pReg = regexp.MustCompile(`^(?P<id>[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12})(:(?P<idx>\d+))?$`)
+
+func parseProcessInstanceHeader(processInstanceHeader string) (string, string, error) {
+	matches := pReg.FindStringSubmatch(processInstanceHeader)
+	if len(matches) == 0 {
+		return "", "", fmt.Errorf("Incorrect %s header : %s", router_http.CfProcessInstance, processInstanceHeader)
+	}
+	return matches[pReg.SubexpIndex("id")], matches[pReg.SubexpIndex("idx")], nil
 }
diff --git a/handlers/lookup_test.go b/handlers/lookup_test.go
index d65b8f68f..cb7f900f0 100644
--- a/handlers/lookup_test.go
+++ b/handlers/lookup_test.go
@@ -39,6 +39,7 @@ var _ = Describe("Lookup", func() {
 	)
 
 	const fakeAppGUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
+	const fakeProcessGUID = "ffffffff-ffff-ffff-ffff-ffffffffffff"
 
 	nextHandler = http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) {
 		nextCalled = true
@@ -155,7 +156,7 @@ var _ = Describe("Lookup", func() {
 			})
 		})
 
-		Context("when an instance header is given", func() {
+		Context("when an app-instance header is given", func() {
 			BeforeEach(func() {
 				req.Header.Add("X-CF-App-Instance", fakeAppGUID+":1")
 			})
@@ -181,6 +182,62 @@ var _ = Describe("Lookup", func() {
 				Expect(resp.Body.String()).To(ContainSubstring("Requested instance ('1') with guid ('%s') does not exist for route ('example.com')", fakeAppGUID))
 			})
 		})
+
+		Context("when a process-instance header is given", func() {
+			Context("when an index is given", func() {
+				BeforeEach(func() {
+					req.Header.Add("X-CF-Process-Instance", fakeProcessGUID+":1")
+				})
+
+				It("sends a bad request metric", func() {
+					Expect(rep.CaptureBadRequestCallCount()).To(Equal(1))
+				})
+
+				It("sets X-Cf-RouterError to unknown_route", func() {
+					Expect(resp.Header().Get("X-Cf-RouterError")).To(Equal("unknown_route"))
+				})
+
+				It("sets Cache-Control to contain no-cache, no-store", func() {
+					Expect(resp.Header().Get("Cache-Control")).To(Equal("no-cache, no-store"))
+				})
+
+				It("returns a 400 BadRequest and does not call next", func() {
+					Expect(nextCalled).To(BeFalse())
+					Expect(resp.Code).To(Equal(http.StatusBadRequest))
+				})
+
+				It("has a meaningful response", func() {
+					Expect(resp.Body.String()).To(ContainSubstring("Requested instance ('1') with process guid ('%s') does not exist for route ('example.com')", fakeProcessGUID))
+				})
+			})
+
+			Context("when an index is not given", func() {
+				BeforeEach(func() {
+					req.Header.Add("X-CF-Process-Instance", fakeProcessGUID)
+				})
+
+				It("sends a bad request metric", func() {
+					Expect(rep.CaptureBadRequestCallCount()).To(Equal(1))
+				})
+
+				It("sets X-Cf-RouterError to unknown_route", func() {
+					Expect(resp.Header().Get("X-Cf-RouterError")).To(Equal("unknown_route"))
+				})
+
+				It("sets Cache-Control to contain no-cache, no-store", func() {
+					Expect(resp.Header().Get("Cache-Control")).To(Equal("no-cache, no-store"))
+				})
+
+				It("returns a 400 BadRequest and does not call next", func() {
+					Expect(nextCalled).To(BeFalse())
+					Expect(resp.Code).To(Equal(http.StatusBadRequest))
+				})
+
+				It("has a meaningful response", func() {
+					Expect(resp.Body.String()).To(ContainSubstring("Requested instance with process guid ('%s') does not exist for route ('example.com')", fakeProcessGUID))
+				})
+			})
+		})
 	})
 
 	Context("when there is a pool that matches the request, but it has no endpoints", func() {
@@ -373,7 +430,7 @@ var _ = Describe("Lookup", func() {
 			})
 		})
 
-		Context("when a specific instance is requested", func() {
+		Context("when a specific app instance is requested", func() {
 			BeforeEach(func() {
 				pool := route.NewPool(&route.PoolOpts{
 					Logger:             logger,
@@ -388,7 +445,7 @@ var _ = Describe("Lookup", func() {
 
 				req.Header.Add("X-CF-App-Instance", fakeAppGUID+":1")
 
-				reg.LookupWithInstanceReturns(pool)
+				reg.LookupWithAppInstanceReturns(pool)
 			})
 
 			JustBeforeEach(func() {
@@ -396,8 +453,8 @@ var _ = Describe("Lookup", func() {
 			})
 
 			It("lookups with instance", func() {
-				Expect(reg.LookupWithInstanceCallCount()).To(Equal(1))
-				uri, appGuid, appIndex := reg.LookupWithInstanceArgsForCall(0)
+				Expect(reg.LookupWithAppInstanceCallCount()).To(Equal(1))
+				uri, appGuid, appIndex := reg.LookupWithAppInstanceArgsForCall(0)
 
 				Expect(uri.String()).To(Equal("example.com"))
 				Expect(appGuid).To(Equal("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))
@@ -405,7 +462,55 @@ var _ = Describe("Lookup", func() {
 			})
 		})
 
-		Context("when an invalid instance header is requested", func() {
+		Context("when a specific process instance is requested", func() {
+			BeforeEach(func() {
+				pool := route.NewPool(&route.PoolOpts{
+					Logger:             logger,
+					RetryAfterFailure:  2 * time.Minute,
+					Host:               "example.com",
+					ContextPath:        "/",
+					MaxConnsPerBackend: maxConnections,
+				})
+				exampleEndpoint := &route.Endpoint{Stats: route.NewStats()}
+				pool.Put(exampleEndpoint)
+				reg.LookupWithAppInstanceReturns(pool)
+
+			})
+
+			JustBeforeEach(func() {
+				handler.ServeHTTP(resp, req)
+			})
+
+			Context("when an index is provided", func() {
+				BeforeEach(func() {
+					req.Header.Add("X-CF-Process-Instance", fakeProcessGUID+":1")
+				})
+				It("lookups with process instance", func() {
+					Expect(reg.LookupWithProcessInstanceCallCount()).To(Equal(1))
+					uri, processGuid, processIndex := reg.LookupWithProcessInstanceArgsForCall(0)
+
+					Expect(uri.String()).To(Equal("example.com"))
+					Expect(processGuid).To(Equal(fakeProcessGUID))
+					Expect(processIndex).To(Equal("1"))
+				})
+			})
+
+			Context("when an index is not provided", func() {
+				BeforeEach(func() {
+					req.Header.Add("X-CF-Process-Instance", fakeProcessGUID)
+				})
+				It("lookups with process instance", func() {
+					Expect(reg.LookupWithProcessInstanceCallCount()).To(Equal(1))
+					uri, processGuid, processIndex := reg.LookupWithProcessInstanceArgsForCall(0)
+
+					Expect(uri.String()).To(Equal("example.com"))
+					Expect(processGuid).To(Equal(fakeProcessGUID))
+					Expect(processIndex).To(Equal(""))
+				})
+			})
+		})
+
+		Context("when an invalid app instance header is requested", func() {
 			BeforeEach(func() {
 				pool := route.NewPool(&route.PoolOpts{
 					Logger:             logger,
@@ -420,7 +525,7 @@ var _ = Describe("Lookup", func() {
 
 				req.Header.Add("X-CF-App-Instance", fakeAppGUID+":1:invalid-part")
 
-				reg.LookupWithInstanceReturns(pool)
+				reg.LookupWithAppInstanceReturns(pool)
 			})
 
 			JustBeforeEach(func() {
@@ -428,7 +533,7 @@ var _ = Describe("Lookup", func() {
 			})
 
 			It("does not lookup the instance", func() {
-				Expect(reg.LookupWithInstanceCallCount()).To(Equal(0))
+				Expect(reg.LookupWithAppInstanceCallCount()).To(Equal(0))
 			})
 
 			It("responds with 400", func() {
@@ -455,6 +560,56 @@ var _ = Describe("Lookup", func() {
 			})
 		})
 
+		Context("when an invalid process instance header is requested", func() {
+			BeforeEach(func() {
+				pool := route.NewPool(&route.PoolOpts{
+					Logger:             logger,
+					RetryAfterFailure:  2 * time.Minute,
+					Host:               "example.com",
+					ContextPath:        "/",
+					MaxConnsPerBackend: maxConnections,
+				})
+				exampleEndpoint := &route.Endpoint{Stats: route.NewStats()}
+				pool.Put(exampleEndpoint)
+				reg.LookupReturns(pool)
+
+				req.Header.Add("X-CF-Process-Instance", fakeProcessGUID+":1:invalid-part")
+
+				reg.LookupWithAppInstanceReturns(pool)
+			})
+
+			JustBeforeEach(func() {
+				handler.ServeHTTP(resp, req)
+			})
+
+			It("does not lookup the instance", func() {
+				Expect(reg.LookupWithAppInstanceCallCount()).To(Equal(0))
+			})
+
+			It("responds with 400", func() {
+				Expect(nextCalled).To(BeFalse())
+				Expect(resp.Code).To(Equal(http.StatusBadRequest))
+			})
+
+			It("responds with an error in the body", func() {
+				body, err := io.ReadAll(resp.Body)
+				Expect(err).NotTo(HaveOccurred())
+				Expect(string(body)).To(Equal("400 Bad Request: Invalid X-CF-Process-Instance Header\n"))
+			})
+
+			It("reports the bad request", func() {
+				Expect(rep.CaptureBadRequestCallCount()).To(Equal(1))
+			})
+
+			It("responds with a X-CF-RouterError header", func() {
+				Expect(resp.Header().Get("X-Cf-RouterError")).To(Equal("invalid_cf_process_instance_header"))
+			})
+
+			It("adds a no-cache header to the response", func() {
+				Expect(resp.Header().Get("Cache-Control")).To(Equal("no-cache, no-store"))
+			})
+		})
+
 		Context("when given an incomplete app instance header", func() {
 			BeforeEach(func() {
 				pool := route.NewPool(&route.PoolOpts{
@@ -470,7 +625,39 @@ var _ = Describe("Lookup", func() {
 
 				appInstanceHeader := fakeAppGUID + ":"
 				req.Header.Add("X-CF-App-Instance", appInstanceHeader)
-				reg.LookupWithInstanceReturns(pool)
+				reg.LookupWithAppInstanceReturns(pool)
+			})
+
+			JustBeforeEach(func() {
+				handler.ServeHTTP(resp, req)
+			})
+
+			It("does not lookup the instance", func() {
+				Expect(reg.LookupWithAppInstanceCallCount()).To(Equal(0))
+			})
+
+			It("responds with 400", func() {
+				Expect(nextCalled).To(BeFalse())
+				Expect(resp.Code).To(Equal(http.StatusBadRequest))
+			})
+		})
+
+		Context("when given an incomplete process instance header", func() {
+			BeforeEach(func() {
+				pool := route.NewPool(&route.PoolOpts{
+					Logger:             logger,
+					RetryAfterFailure:  2 * time.Minute,
+					Host:               "example.com",
+					ContextPath:        "/",
+					MaxConnsPerBackend: maxConnections,
+				})
+				exampleEndpoint := &route.Endpoint{Stats: route.NewStats()}
+				pool.Put(exampleEndpoint)
+				reg.LookupReturns(pool)
+
+				processInstanceHeader := fakeAppGUID + ":"
+				req.Header.Add("X-CF-Process-Instance", processInstanceHeader)
+				reg.LookupWithAppInstanceReturns(pool)
 			})
 
 			JustBeforeEach(func() {
@@ -478,7 +665,7 @@ var _ = Describe("Lookup", func() {
 			})
 
 			It("does not lookup the instance", func() {
-				Expect(reg.LookupWithInstanceCallCount()).To(Equal(0))
+				Expect(reg.LookupWithAppInstanceCallCount()).To(Equal(0))
 			})
 
 			It("responds with 400", func() {
@@ -502,7 +689,7 @@ var _ = Describe("Lookup", func() {
 
 				appInstanceHeader := fakeAppGUID
 				req.Header.Add("X-CF-App-Instance", appInstanceHeader)
-				reg.LookupWithInstanceReturns(pool)
+				reg.LookupWithAppInstanceReturns(pool)
 			})
 
 			JustBeforeEach(func() {
@@ -510,7 +697,7 @@ var _ = Describe("Lookup", func() {
 			})
 
 			It("does not lookup the instance", func() {
-				Expect(reg.LookupWithInstanceCallCount()).To(Equal(0))
+				Expect(reg.LookupWithAppInstanceCallCount()).To(Equal(0))
 			})
 
 			It("responds with 400", func() {
diff --git a/registry/fakes/fake_registry.go b/registry/fakes/fake_registry.go
index 0dd1f4846..0ab33da26 100644
--- a/registry/fakes/fake_registry.go
+++ b/registry/fakes/fake_registry.go
@@ -20,17 +20,30 @@ type FakeRegistry struct {
 	lookupReturnsOnCall map[int]struct {
 		result1 *route.EndpointPool
 	}
-	LookupWithInstanceStub        func(route.Uri, string, string) *route.EndpointPool
-	lookupWithInstanceMutex       sync.RWMutex
-	lookupWithInstanceArgsForCall []struct {
+	LookupWithAppInstanceStub        func(route.Uri, string, string) *route.EndpointPool
+	lookupWithAppInstanceMutex       sync.RWMutex
+	lookupWithAppInstanceArgsForCall []struct {
 		arg1 route.Uri
 		arg2 string
 		arg3 string
 	}
-	lookupWithInstanceReturns struct {
+	lookupWithAppInstanceReturns struct {
 		result1 *route.EndpointPool
 	}
-	lookupWithInstanceReturnsOnCall map[int]struct {
+	lookupWithAppInstanceReturnsOnCall map[int]struct {
+		result1 *route.EndpointPool
+	}
+	LookupWithProcessInstanceStub        func(route.Uri, string, string) *route.EndpointPool
+	lookupWithProcessInstanceMutex       sync.RWMutex
+	lookupWithProcessInstanceArgsForCall []struct {
+		arg1 route.Uri
+		arg2 string
+		arg3 string
+	}
+	lookupWithProcessInstanceReturns struct {
+		result1 *route.EndpointPool
+	}
+	lookupWithProcessInstanceReturnsOnCall map[int]struct {
 		result1 *route.EndpointPool
 	}
 	RegisterStub        func(route.Uri, *route.Endpoint)
@@ -110,18 +123,81 @@ func (fake *FakeRegistry) LookupReturnsOnCall(i int, result1 *route.EndpointPool
 	}{result1}
 }
 
-func (fake *FakeRegistry) LookupWithInstance(arg1 route.Uri, arg2 string, arg3 string) *route.EndpointPool {
-	fake.lookupWithInstanceMutex.Lock()
-	ret, specificReturn := fake.lookupWithInstanceReturnsOnCall[len(fake.lookupWithInstanceArgsForCall)]
-	fake.lookupWithInstanceArgsForCall = append(fake.lookupWithInstanceArgsForCall, struct {
+func (fake *FakeRegistry) LookupWithAppInstance(arg1 route.Uri, arg2 string, arg3 string) *route.EndpointPool {
+	fake.lookupWithAppInstanceMutex.Lock()
+	ret, specificReturn := fake.lookupWithAppInstanceReturnsOnCall[len(fake.lookupWithAppInstanceArgsForCall)]
+	fake.lookupWithAppInstanceArgsForCall = append(fake.lookupWithAppInstanceArgsForCall, struct {
+		arg1 route.Uri
+		arg2 string
+		arg3 string
+	}{arg1, arg2, arg3})
+	stub := fake.LookupWithAppInstanceStub
+	fakeReturns := fake.lookupWithAppInstanceReturns
+	fake.recordInvocation("LookupWithAppInstance", []interface{}{arg1, arg2, arg3})
+	fake.lookupWithAppInstanceMutex.Unlock()
+	if stub != nil {
+		return stub(arg1, arg2, arg3)
+	}
+	if specificReturn {
+		return ret.result1
+	}
+	return fakeReturns.result1
+}
+
+func (fake *FakeRegistry) LookupWithAppInstanceCallCount() int {
+	fake.lookupWithAppInstanceMutex.RLock()
+	defer fake.lookupWithAppInstanceMutex.RUnlock()
+	return len(fake.lookupWithAppInstanceArgsForCall)
+}
+
+func (fake *FakeRegistry) LookupWithAppInstanceCalls(stub func(route.Uri, string, string) *route.EndpointPool) {
+	fake.lookupWithAppInstanceMutex.Lock()
+	defer fake.lookupWithAppInstanceMutex.Unlock()
+	fake.LookupWithAppInstanceStub = stub
+}
+
+func (fake *FakeRegistry) LookupWithAppInstanceArgsForCall(i int) (route.Uri, string, string) {
+	fake.lookupWithAppInstanceMutex.RLock()
+	defer fake.lookupWithAppInstanceMutex.RUnlock()
+	argsForCall := fake.lookupWithAppInstanceArgsForCall[i]
+	return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3
+}
+
+func (fake *FakeRegistry) LookupWithAppInstanceReturns(result1 *route.EndpointPool) {
+	fake.lookupWithAppInstanceMutex.Lock()
+	defer fake.lookupWithAppInstanceMutex.Unlock()
+	fake.LookupWithAppInstanceStub = nil
+	fake.lookupWithAppInstanceReturns = struct {
+		result1 *route.EndpointPool
+	}{result1}
+}
+
+func (fake *FakeRegistry) LookupWithAppInstanceReturnsOnCall(i int, result1 *route.EndpointPool) {
+	fake.lookupWithAppInstanceMutex.Lock()
+	defer fake.lookupWithAppInstanceMutex.Unlock()
+	fake.LookupWithAppInstanceStub = nil
+	if fake.lookupWithAppInstanceReturnsOnCall == nil {
+		fake.lookupWithAppInstanceReturnsOnCall = make(map[int]struct {
+			result1 *route.EndpointPool
+		})
+	}
+	fake.lookupWithAppInstanceReturnsOnCall[i] = struct {
+		result1 *route.EndpointPool
+	}{result1}
+}
+
+func (fake *FakeRegistry) LookupWithProcessInstance(arg1 route.Uri, arg2 string, arg3 string) *route.EndpointPool {
+	fake.lookupWithProcessInstanceMutex.Lock()
+	ret, specificReturn := fake.lookupWithProcessInstanceReturnsOnCall[len(fake.lookupWithProcessInstanceArgsForCall)]
+	fake.lookupWithProcessInstanceArgsForCall = append(fake.lookupWithProcessInstanceArgsForCall, struct {
 		arg1 route.Uri
 		arg2 string
 		arg3 string
 	}{arg1, arg2, arg3})
-	stub := fake.LookupWithInstanceStub
-	fakeReturns := fake.lookupWithInstanceReturns
-	fake.recordInvocation("LookupWithInstance", []interface{}{arg1, arg2, arg3})
-	fake.lookupWithInstanceMutex.Unlock()
+	stub := fake.LookupWithProcessInstanceStub
+	fakeReturns := fake.lookupWithProcessInstanceReturns
+	fake.recordInvocation("LookupWithProcessInstance", []interface{}{arg1, arg2, arg3})
+	fake.lookupWithProcessInstanceMutex.Unlock()
 	if stub != nil {
 		return stub(arg1, arg2, arg3)
 	}
@@ -131,44 +207,44 @@ func (fake *FakeRegistry) LookupWithInstance(arg1 route.Uri, arg2 string, arg3 s
 	return fakeReturns.result1
 }
 
-func (fake *FakeRegistry) LookupWithInstanceCallCount() int {
-	fake.lookupWithInstanceMutex.RLock()
-	defer fake.lookupWithInstanceMutex.RUnlock()
-	return len(fake.lookupWithInstanceArgsForCall)
+func (fake *FakeRegistry) LookupWithProcessInstanceCallCount() int {
+	fake.lookupWithProcessInstanceMutex.RLock()
+	defer fake.lookupWithProcessInstanceMutex.RUnlock()
+	return len(fake.lookupWithProcessInstanceArgsForCall)
 }
 
-func (fake *FakeRegistry) LookupWithInstanceCalls(stub func(route.Uri, string, string) *route.EndpointPool) {
-	fake.lookupWithInstanceMutex.Lock()
-	defer fake.lookupWithInstanceMutex.Unlock()
-	fake.LookupWithInstanceStub = stub
+func (fake *FakeRegistry) LookupWithProcessInstanceCalls(stub func(route.Uri, string, string) *route.EndpointPool) {
+	fake.lookupWithProcessInstanceMutex.Lock()
+	defer fake.lookupWithProcessInstanceMutex.Unlock()
+	fake.LookupWithProcessInstanceStub = stub
 }
 
-func (fake *FakeRegistry) LookupWithInstanceArgsForCall(i int) (route.Uri, string, string) {
-	fake.lookupWithInstanceMutex.RLock()
-	defer fake.lookupWithInstanceMutex.RUnlock()
-	argsForCall := fake.lookupWithInstanceArgsForCall[i]
+func (fake *FakeRegistry) LookupWithProcessInstanceArgsForCall(i int) (route.Uri, string, string) {
+	fake.lookupWithProcessInstanceMutex.RLock()
+	defer fake.lookupWithProcessInstanceMutex.RUnlock()
+	argsForCall := fake.lookupWithProcessInstanceArgsForCall[i]
 	return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3
 }
 
-func (fake *FakeRegistry) LookupWithInstanceReturns(result1 *route.EndpointPool) {
-	fake.lookupWithInstanceMutex.Lock()
-	defer fake.lookupWithInstanceMutex.Unlock()
-	fake.LookupWithInstanceStub = nil
-	fake.lookupWithInstanceReturns = struct {
+func (fake *FakeRegistry) LookupWithProcessInstanceReturns(result1 *route.EndpointPool) {
+	fake.lookupWithProcessInstanceMutex.Lock()
+	defer fake.lookupWithProcessInstanceMutex.Unlock()
+	fake.LookupWithProcessInstanceStub = nil
+	fake.lookupWithProcessInstanceReturns = struct {
 		result1 *route.EndpointPool
 	}{result1}
 }
 
-func (fake *FakeRegistry) LookupWithInstanceReturnsOnCall(i int, result1 *route.EndpointPool) {
-	fake.lookupWithInstanceMutex.Lock()
-	defer fake.lookupWithInstanceMutex.Unlock()
-	fake.LookupWithInstanceStub = nil
-	if fake.lookupWithInstanceReturnsOnCall == nil {
-		fake.lookupWithInstanceReturnsOnCall = make(map[int]struct {
+func (fake *FakeRegistry) LookupWithProcessInstanceReturnsOnCall(i int, result1 *route.EndpointPool) {
+	fake.lookupWithProcessInstanceMutex.Lock()
+	defer fake.lookupWithProcessInstanceMutex.Unlock()
+	fake.LookupWithProcessInstanceStub = nil
+	if fake.lookupWithProcessInstanceReturnsOnCall == nil {
+		fake.lookupWithProcessInstanceReturnsOnCall = make(map[int]struct {
 			result1 *route.EndpointPool
 		})
 	}
-	fake.lookupWithInstanceReturnsOnCall[i] = struct {
+	fake.lookupWithProcessInstanceReturnsOnCall[i] = struct {
 		result1 *route.EndpointPool
 	}{result1}
 }
@@ -244,8 +320,10 @@ func (fake *FakeRegistry) Invocations() map[string][][]interface{} {
 	defer fake.invocationsMutex.RUnlock()
 	fake.lookupMutex.RLock()
 	defer fake.lookupMutex.RUnlock()
-	fake.lookupWithInstanceMutex.RLock()
-	defer fake.lookupWithInstanceMutex.RUnlock()
+	fake.lookupWithAppInstanceMutex.RLock()
+	defer fake.lookupWithAppInstanceMutex.RUnlock()
+	fake.lookupWithProcessInstanceMutex.RLock()
+	defer fake.lookupWithProcessInstanceMutex.RUnlock()
 	fake.registerMutex.RLock()
 	defer fake.registerMutex.RUnlock()
 	fake.unregisterMutex.RLock()
diff --git a/registry/registry.go b/registry/registry.go
index f2af89806..2f8b89974 100644
--- a/registry/registry.go
+++ b/registry/registry.go
@@ -20,7 +20,8 @@ type Registry interface {
 	Register(uri route.Uri, endpoint *route.Endpoint)
 	Unregister(uri route.Uri, endpoint *route.Endpoint)
 	Lookup(uri route.Uri) *route.EndpointPool
-	LookupWithInstance(uri route.Uri, appID, appIndex string) *route.EndpointPool
+	LookupWithAppInstance(uri route.Uri, appID, appIndex string) *route.EndpointPool
+	LookupWithProcessInstance(uri route.Uri, processID, processIndex string) *route.EndpointPool
 }
 
 type PruneStatus int
@@ -249,7 +250,7 @@ func (r *RouteRegistry) endpointInRouterShard(endpoint *route.Endpoint) bool {
 	return false
 }
 
-func (r *RouteRegistry) LookupWithInstance(uri route.Uri, appID string, appIndex string) *route.EndpointPool {
+func (r *RouteRegistry) LookupWithAppInstance(uri route.Uri, appID string, appIndex string) *route.EndpointPool {
 	uri = uri.RouteKey()
 	p := r.Lookup(uri)
 
@@ -276,6 +277,35 @@ func (r *RouteRegistry) LookupWithInstance(uri route.Uri, appID string, appIndex
 	return surgicalPool
 }
 
+func (r *RouteRegistry) LookupWithProcessInstance(uri route.Uri, processID string, processIndex string) *route.EndpointPool {
+	uri = uri.RouteKey()
+	p := r.Lookup(uri)
+
+	if p == nil {
+		return nil
+	}
+
+	var surgicalPool *route.EndpointPool
+
+	p.Each(func(e *route.Endpoint) {
+		if (e.ProcessId() == processID) && (e.PrivateInstanceIndex == processIndex || processIndex == "") {
+			if surgicalPool == nil {
+				surgicalPool = route.NewPool(&route.PoolOpts{
+					Logger:                 r.logger,
+					RetryAfterFailure:      0,
+					Host:                   p.Host(),
+					ContextPath:            p.ContextPath(),
+					MaxConnsPerBackend:     p.MaxConnsPerBackend(),
+					LoadBalancingAlgorithm: p.LoadBalancingAlgorithm,
+				})
+			}
+			surgicalPool.Put(e)
+		}
+	})
+
+	return surgicalPool
+}
+
 func (r *RouteRegistry) StartPruningCycle() {
 	if r.pruneStaleDropletsInterval > 0 {
 		r.Lock()
diff --git a/registry/registry_test.go b/registry/registry_test.go
index f4e3d05ba..93875ce64 100644
--- a/registry/registry_test.go
+++ b/registry/registry_test.go
@@ -1012,7 +1012,7 @@ var _ = Describe("RouteRegistry", func() {
 		})
 	})
 
-	Context("LookupWithInstance with specified load balancing algorithms for routes", func() {
+	Context("LookupWithAppInstance with specified load balancing algorithms for routes", func() {
 		var (
 			appId    string
 			appIndex string
@@ -1026,7 +1026,7 @@ var _ = Describe("RouteRegistry", func() {
 		})
 
 		It("sets a default load balancing algorithm of a pool if no value is specified for a route", func() {
-			p := r.LookupWithInstance("bar.com/foo", appId, appIndex)
+			p := r.LookupWithAppInstance("bar.com/foo", appId, appIndex)
 
 			Expect(p).ToNot(BeNil())
 			Expect(p.LoadBalancingAlgorithm).To(Equal("round-robin"))
@@ -1036,7 +1036,7 @@ var _ = Describe("RouteRegistry", func() {
 			m2 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-2-ID", Host: "192.168.1.2", Port: 1235, PrivateInstanceIndex: "0", LoadBalancingAlgorithm: ""})
 			r.Register("bar.com/foo", m2)
 
-			p := r.LookupWithInstance("bar.com/foo", "app-2-ID", appIndex)
+			p := r.LookupWithAppInstance("bar.com/foo", "app-2-ID", appIndex)
 
 			Expect(p).ToNot(BeNil())
 			Expect(p.LoadBalancingAlgorithm).To(Equal("round-robin"))
@@ -1046,7 +1046,7 @@ var _ = Describe("RouteRegistry", func() {
 			m3 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-3-ID", Host: "192.168.1.3", Port: 1235, PrivateInstanceIndex: "0", LoadBalancingAlgorithm: "least-connection"})
 			r.Register("bar.com/foo", m3)
 
-			p := r.LookupWithInstance("bar.com/foo", "app-3-ID", appIndex)
+			p := r.LookupWithAppInstance("bar.com/foo", "app-3-ID", appIndex)
 
 			Expect(p).ToNot(BeNil())
 			Expect(p.LoadBalancingAlgorithm).To(Equal("least-connection"))
@@ -1056,14 +1056,14 @@ var _ = Describe("RouteRegistry", func() {
 			m4 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-4-ID", Host: "192.168.1.4", Port: 1235, PrivateInstanceIndex: "0", LoadBalancingAlgorithm: "round-robin"})
 			r.Register("bar.com/foo", m4)
 
-			p := r.LookupWithInstance("bar.com/foo", "app-4-ID", appIndex)
+			p := r.LookupWithAppInstance("bar.com/foo", "app-4-ID", appIndex)
 
 			Expect(p).ToNot(BeNil())
 			Expect(p.LoadBalancingAlgorithm).To(Equal("round-robin"))
 		})
 	})
 
-	Context("LookupWithInstance", func() {
+	Context("LookupWithAppInstance", func() {
 		var (
 			appId    string
 			appIndex string
@@ -1084,7 +1084,7 @@ var _ = Describe("RouteRegistry", func() {
 			Expect(r.NumUris()).To(Equal(1))
 			Expect(r.NumEndpoints()).To(Equal(2))
 
-			p := r.LookupWithInstance("bar.com/foo", appId, appIndex)
+			p := r.LookupWithAppInstance("bar.com/foo", appId, appIndex)
 			e := p.Endpoints(logger.Logger, "", false, azPreference, az).Next(0)
 
 			Expect(e).ToNot(BeNil())
@@ -1095,7 +1095,7 @@ var _ = Describe("RouteRegistry", func() {
 		})
 
 		It("load balancing algorithm of an endpoint is not set and pool has a default load balancing value", func() {
-			p := r.LookupWithInstance("bar.com/foo", appId, appIndex)
+			p := r.LookupWithAppInstance("bar.com/foo", appId, appIndex)
 
 			Expect(p).ToNot(BeNil())
 			Expect(p.LoadBalancingAlgorithm).To(Equal("round-robin"))
@@ -1105,7 +1105,7 @@ var _ = Describe("RouteRegistry", func() {
 			Expect(r.NumUris()).To(Equal(1))
 			Expect(r.NumEndpoints()).To(Equal(2))
 
-			p := r.LookupWithInstance("bar.com/foo", appId, appIndex)
+			p := r.LookupWithAppInstance("bar.com/foo", appId, appIndex)
 			e := p.Endpoints(logger.Logger, "", false, azPreference, az).Next(0)
 
 			Expect(e).ToNot(BeNil())
@@ -1120,35 +1120,196 @@ var _ = Describe("RouteRegistry", func() {
 
 		Context("when lookup fails to find any routes", func() {
 			It("returns nil", func() {
-				p := r.LookupWithInstance("foo", appId, appIndex)
+				p := r.LookupWithAppInstance("foo", appId, appIndex)
 				Expect(p).To(BeNil())
 			})
 		})
+	})
+
+	Context("LookupWithProcessInstance with specified load balancing algorithms for routes", func() {
+		var (
+			processId    string
+			processIndex string
+			tags         map[string]string
+		)
+
+		BeforeEach(func() {
+			processId = "meow-correct"
+			processIndex = "0"
+			tags = map[string]string{"process_id": processId}
+			m1 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-1-ID", Host: "192.168.1.1", Port: 1234, PrivateInstanceIndex: "0", Tags: tags})
+			r.Register("bar.com/foo", m1)
+		})
+
+		It("sets a default load balancing algorithm of a pool if no value is specified for a route", func() {
+			p := r.LookupWithProcessInstance("bar.com/foo", processId, processIndex)
+
+			Expect(p).ToNot(BeNil())
+			Expect(p.LoadBalancingAlgorithm).To(Equal("round-robin"))
+		})
+
+		It("keeps the default load balancing algorithm of a pool if the value for an endpoint is an empty string", func() {
+			m2 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-2-ID", Host: "192.168.1.2", Port: 1235, PrivateInstanceIndex: "0", LoadBalancingAlgorithm: "", Tags: tags})
+			r.Register("bar.com/foo", m2)
+
+			p := r.LookupWithProcessInstance("bar.com/foo", processId, processIndex)
+
+			Expect(p).ToNot(BeNil())
+			Expect(p.LoadBalancingAlgorithm).To(Equal("round-robin"))
+		})
+
+		It("sets the load balancing algorithm of pool to that of a newly added endpoint", func() {
+			m3 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-3-ID", Host: "192.168.1.3", Port: 1235, PrivateInstanceIndex: "0", LoadBalancingAlgorithm: "least-connection", Tags: tags})
+			r.Register("bar.com/foo", m3)
 
-		Context("when given an incorrect app index", func() {
+			p := r.LookupWithProcessInstance("bar.com/foo", processId, processIndex)
+
+			Expect(p).ToNot(BeNil())
+			Expect(p.LoadBalancingAlgorithm).To(Equal("least-connection"))
+		})
+
+		It("repeatedly sets the load balancing algorithm of pool to that of a newly added endpoint", func() {
+			m4 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-4-ID", Host: "192.168.1.4", Port: 1235, PrivateInstanceIndex: "0", LoadBalancingAlgorithm: "round-robin", Tags: tags})
+			r.Register("bar.com/foo", m4)
+
+			p := r.LookupWithProcessInstance("bar.com/foo", processId, processIndex)
+
+			Expect(p).ToNot(BeNil())
+			Expect(p.LoadBalancingAlgorithm).To(Equal("round-robin"))
+		})
+	})
+
+	Context("LookupWithProcessInstance", func() {
+		var (
+			processId    string
+			processIndex string
+		)
+
+		BeforeEach(func() {
+			processId = "meow-correct"
+			m1 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-1-ID", Host: "192.168.1.1", Port: 1234, PrivateInstanceIndex: "0"})
+			tagsWithoutProcessID := map[string]string{"meow": "meow"}
+			m2 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-2-ID", Host: "192.168.1.2", Port: 1235, PrivateInstanceIndex: "1", Tags: tagsWithoutProcessID})
+			tagsWithWrongProcessID := map[string]string{"process_id": "meow-wrong"}
+			m3 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-3-ID", Host: "192.168.1.3", Port: 1236, PrivateInstanceIndex: "0", Tags: tagsWithWrongProcessID})
+			tagsWithCorrectProcessID := map[string]string{"process_id": processId}
+			m4 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-4-ID", Host: "192.168.1.4", Port: 1237, PrivateInstanceIndex: "0", Tags: tagsWithCorrectProcessID, PrivateInstanceId: "meow1"})
+			m5 := route.NewEndpoint(&route.EndpointOpts{AppId: "app-5-ID", Host: "192.168.1.5", Port: 1238, PrivateInstanceIndex: "1", Tags: tagsWithCorrectProcessID, PrivateInstanceId: "meow2"})
+
+			r.Register("bar.com/foo", m1)
+			r.Register("bar.com/foo", m2)
+			r.Register("bar.com/foo", m3)
+			r.Register("bar.com/foo", m4)
+			r.Register("bar.com/foo", m5)
+		})
+
+		Context("when processIndex is not provided", func() {
+			BeforeEach(func() {
+				processIndex = ""
+			})
+
+			It("selects a route with the matching process id", func() {
+				Expect(r.NumUris()).To(Equal(1))
+				Expect(r.NumEndpoints()).To(Equal(5))
+
+				p := r.LookupWithProcessInstance("bar.com/foo", processId, processIndex)
+				Expect(p.NumEndpoints()).To(Equal(2))
+				es := p.Endpoints(logger.Logger, "", false, azPreference, az)
+				e1 := es.Next(0)
+				Expect(e1).ToNot(BeNil())
+				e2 := es.Next(0)
+				Expect(e2).ToNot(BeNil())
+
+				Expect([]string{e1.CanonicalAddr(), e2.CanonicalAddr()}).To(ContainElements("192.168.1.4:1237", "192.168.1.5:1238"))
+
+				Expect(r.NumUris()).To(Equal(1))
+				Expect(r.NumEndpoints()).To(Equal(5))
+			})
+
+			It("load balancing algorithm of an endpoint is not set and pool has a default load balancing value", func() {
+				p := r.LookupWithProcessInstance("bar.com/foo", processId, processIndex)
+
+				Expect(p).ToNot(BeNil())
+				Expect(p.LoadBalancingAlgorithm).To(Equal("round-robin"))
+			})
+
+			It("returns a pool that matches the result of Lookup", func() {
+				Expect(r.NumUris()).To(Equal(1))
+				Expect(r.NumEndpoints()).To(Equal(5))
+
+				p := r.LookupWithProcessInstance("bar.com/foo", processId, processIndex)
+				p2 := r.Lookup("bar.com/foo")
+				Expect(route.PoolsMatch(p, p2)).To(BeTrue())
+			})
+		})
+
+		Context("when processIndex is provided", func() {
+			BeforeEach(func() {
+				processIndex = "0"
+			})
+
+			It("selects the route with the matching process id and index", func() {
+				Expect(r.NumUris()).To(Equal(1))
+				Expect(r.NumEndpoints()).To(Equal(5))
+
+				p := r.LookupWithProcessInstance("bar.com/foo", processId, processIndex)
+				e := p.Endpoints(logger.Logger, "", false, azPreference, az).Next(0)
+
+				Expect(e).ToNot(BeNil())
+				Expect(e.CanonicalAddr()).To(MatchRegexp("192.168.1.4:1237"))
+
+				Expect(r.NumUris()).To(Equal(1))
+				Expect(r.NumEndpoints()).To(Equal(5))
+			})
+
+			It("load balancing algorithm of an endpoint is not set and pool has a default load balancing value", func() {
+				p := r.LookupWithProcessInstance("bar.com/foo", processId, processIndex)
+
+				Expect(p).ToNot(BeNil())
+				Expect(p.LoadBalancingAlgorithm).To(Equal("round-robin"))
+			})
+
+			It("returns a pool that matches the result of Lookup", func() {
+				Expect(r.NumUris()).To(Equal(1))
+				Expect(r.NumEndpoints()).To(Equal(5))
+
+				p := r.LookupWithProcessInstance("bar.com/foo", processId, processIndex)
+				p2 := r.Lookup("bar.com/foo")
+				Expect(route.PoolsMatch(p, p2)).To(BeTrue())
+			})
+		})
+
+		Context("when lookup fails to find any routes", func() {
+			It("returns nil", func() {
+				p := r.LookupWithProcessInstance("bar.com/foo-meow-not-a-registered-route", processId, processIndex)
+				Expect(p).To(BeNil())
+			})
+		})
+
+		Context("when given an incorrect process index", func() {
 			BeforeEach(func() {
-				appId = "app-2-ID"
-				appIndex = "94"
+				processId = "app-2-ID"
+				processIndex = "94"
 			})
 
 			It("returns a nil pool", func() {
 				Expect(r.NumUris()).To(Equal(1))
-				Expect(r.NumEndpoints()).To(Equal(2))
-				p := r.LookupWithInstance("bar.com/foo", appId, appIndex)
+				Expect(r.NumEndpoints()).To(Equal(5))
+				p := r.LookupWithAppInstance("bar.com/foo", processId, processIndex)
 				Expect(p).To(BeNil())
 			})
 		})
 
-		Context("when given an incorrect app id", func() {
+		Context("when given an incorrect process id", func() {
 			BeforeEach(func() {
-				appId = "app-none-ID"
-				appIndex = "0"
+				processId = "process-none-ID"
+				processIndex = "0"
 			})
 
 			It("returns a nil pool ", func() {
 				Expect(r.NumUris()).To(Equal(1))
-				Expect(r.NumEndpoints()).To(Equal(2))
-				p := r.LookupWithInstance("bar.com/foo", appId, appIndex)
+				Expect(r.NumEndpoints()).To(Equal(5))
+				p := r.LookupWithAppInstance("bar.com/foo", processId, processIndex)
 				Expect(p).To(BeNil())
 			})
 		})
diff --git a/route/pool.go b/route/pool.go
index f58d0e6bc..f90c6947f 100644
--- a/route/pool.go
+++ b/route/pool.go
@@ -122,6 +122,10 @@ func (e *Endpoint) Equal(e2 *Endpoint) bool {
 
 }
 
+func (e *Endpoint) ProcessId() string {
+	return e.Tags["process_id"]
+}
+
 //go:generate counterfeiter -o fakes/fake_endpoint_iterator.go . EndpointIterator
 type EndpointIterator interface {
 	// Next MUST either return the next endpoint available or nil. It MUST NOT return the same endpoint.
diff --git a/route/pool_test.go b/route/pool_test.go
index ee4bd31e4..c8f6c594b 100644
--- a/route/pool_test.go
+++ b/route/pool_test.go
@@ -880,4 +880,55 @@ var _ = Describe("EndpointPool", func() {
 			Expect(string(json)).To(Equal(`[{"address":"1.2.3.4:5678","availability_zone":"az-meow","protocol":"http2","tls":false,"ttl":-1,"route_service_url":"https://my-rs.com","tags":{}}]`))
 		})
 	})
+
+	Describe("ProcessId", func() {
+		Context("when there are no tags", func() {
+			It("returns an empty string", func() {
+				e := route.NewEndpoint(&route.EndpointOpts{
+					AvailabilityZone:        "az-meow",
+					Host:                    "1.2.3.4",
+					Port:                    5678,
+					Protocol:                "http2",
+					RouteServiceUrl:         "https://my-rs.com",
+					StaleThresholdInSeconds: -1,
+				})
+
+				Expect(e.ProcessId()).To(BeEmpty())
+			})
+		})
+
+		Context("when there are tags, but no process_id entry", func() {
+			It("returns an empty string", func() {
+				tags := map[string]string{"meow": "meow"}
+				e := route.NewEndpoint(&route.EndpointOpts{
+					AvailabilityZone:        "az-meow",
+					Host:                    "1.2.3.4",
+					Port:                    5678,
+					Protocol:                "http2",
+					RouteServiceUrl:         "https://my-rs.com",
+					StaleThresholdInSeconds: -1,
+					Tags:                    tags,
+				})
+
+				Expect(e.ProcessId()).To(BeEmpty())
+			})
+		})
+
+		Context("when there are tags, and process_id exists", func() {
+			It("returns the process_id", func() {
+				tags := map[string]string{"process_id": "meow"}
+				e := route.NewEndpoint(&route.EndpointOpts{
+					AvailabilityZone:        "az-meow",
+					Host:                    "1.2.3.4",
+					Port:                    5678,
+					Protocol:                "http2",
+					RouteServiceUrl:         "https://my-rs.com",
+					StaleThresholdInSeconds: -1,
+					Tags:                    tags,
+				})
+
+				Expect(e.ProcessId()).To(Equal("meow"))
+			})
+		})
+	})
 })


Reviews:
user: login: tcdowney
body: Overall LGTM. Just had one question on behavior.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-17T22:13:30Z
user: login: maxmoehl
body: To make sure I get this right:
* an application can consist of multiple processes,
* a process is a "version" of the app and can consist of multiple instances, and
* an instance is a single instance of a process?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T08:01:08Z
user: login: Samze
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-18T15:58:55Z
user: login: ameowlia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T16:39:07Z
user: login: ameowlia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T16:39:24Z
user: login: ameowlia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T16:39:42Z
user: login: maxmoehl
body: Code looks good to me! Still needs someone to run the tests and verify the deployment works as expected, I could look into that later this week if no one else has time.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T16:48:45Z
user: login: Samze
state: APPROVED
author_association: NONE
submitted_at: 2025-03-18T18:04:37Z

Review comments:
diff_hunk: @@ -215,7 +260,20 @@ func validateInstanceHeader(appInstanceHeader string) error {
 	return nil
 }
 
-func splitInstanceHeader(appInstanceHeader string) (string, string) {
-	appDetails := strings.Split(appInstanceHeader, ":")
-	return appDetails[0], appDetails[1]
+func validateProcessInstanceHeader(processInstanceHeader string) error {
+	// Regex to match format of `PROCESS_GUID:INSTANCE_ID`
+	//   and to match format of `PROCESS_GUID`
+	r := regexp.MustCompile(`^[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12}(:\d+)?$`)
+	if !r.MatchString(processInstanceHeader) {
+		return fmt.Errorf("Incorrect %s header : %s", router_http.CfProcessInstance, processInstanceHeader)
+	}
+	return nil
+}
+
+func splitInstanceHeader(instanceHeader string) (string, string) {
+	details := strings.Split(instanceHeader, ":")
path: handlers/lookup.go
user: login: maxmoehl
body: This can cut off additional data, if `foo:bar:baz` is entered it would return `foo` and `bar` omitting `baz`.

In general I would prefer to combine the validation and splitting. I you have a regex anyway you can use capture groups to extract the right strings: https://go.dev/play/p/cSGTcq4ntsW (same goes for the app id logic).

Alternative would be to use `strings.SplitN(instanceHeader, ":", 2)`.
created_at: 2025-03-18T07:53:00Z
author_association: MEMBER
diff_hunk: @@ -152,6 +179,12 @@ func (l *lookupHandler) handleMissingRoute(rw http.ResponseWriter, r *http.Reque
 		returnStatus = http.StatusBadRequest
 	}
 
+	if processInstanceHeader := r.Header.Get(router_http.CfProcessInstance); processInstanceHeader != "" {
+		guid, idx := splitInstanceHeader(processInstanceHeader)
+		errorMsg = fmt.Sprintf("Requested instance ('%s') with process guid ('%s') does not exist for route ('%s')", idx, guid, r.Host)
path: handlers/lookup.go
user: login: maxmoehl
body: `idx` could be empty, maybe we need two variations of this message?
created_at: 2025-03-18T07:57:33Z
author_association: MEMBER
diff_hunk: @@ -215,7 +260,20 @@ func validateInstanceHeader(appInstanceHeader string) error {
 	return nil
 }
 
-func splitInstanceHeader(appInstanceHeader string) (string, string) {
-	appDetails := strings.Split(appInstanceHeader, ":")
-	return appDetails[0], appDetails[1]
+func validateProcessInstanceHeader(processInstanceHeader string) error {
+	// Regex to match format of `PROCESS_GUID:INSTANCE_ID`
+	//   and to match format of `PROCESS_GUID`
+	r := regexp.MustCompile(`^[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12}(:\d+)?$`)
path: handlers/lookup.go
user: login: Samze
body: **nit/suggestion** Compiling the regex at the module level, so its compiled just once, would be a slight performance optimisation. 
created_at: 2025-03-18T15:58:54Z
author_association: NONE
diff_hunk: @@ -152,6 +179,12 @@ func (l *lookupHandler) handleMissingRoute(rw http.ResponseWriter, r *http.Reque
 		returnStatus = http.StatusBadRequest
 	}
 
+	if processInstanceHeader := r.Header.Get(router_http.CfProcessInstance); processInstanceHeader != "" {
+		guid, idx := splitInstanceHeader(processInstanceHeader)
+		errorMsg = fmt.Sprintf("Requested instance ('%s') with process guid ('%s') does not exist for route ('%s')", idx, guid, r.Host)
path: handlers/lookup.go
user: login: ameowlia
body: Updated. Can you check now: https://github.com/cloudfoundry/gorouter/pull/467/commits/2183ada063308958a17993bd2c0a17fba4548b39
created_at: 2025-03-18T16:39:07Z
author_association: MEMBER
diff_hunk: @@ -215,7 +260,20 @@ func validateInstanceHeader(appInstanceHeader string) error {
 	return nil
 }
 
-func splitInstanceHeader(appInstanceHeader string) (string, string) {
-	appDetails := strings.Split(appInstanceHeader, ":")
-	return appDetails[0], appDetails[1]
+func validateProcessInstanceHeader(processInstanceHeader string) error {
+	// Regex to match format of `PROCESS_GUID:INSTANCE_ID`
+	//   and to match format of `PROCESS_GUID`
+	r := regexp.MustCompile(`^[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12}(:\d+)?$`)
path: handlers/lookup.go
user: login: ameowlia
body: done: https://github.com/cloudfoundry/gorouter/pull/467/commits/7511e5001df5bd1b2f356dce8e1481821241248b
created_at: 2025-03-18T16:39:24Z
author_association: MEMBER
diff_hunk: @@ -215,7 +260,20 @@ func validateInstanceHeader(appInstanceHeader string) error {
 	return nil
 }
 
-func splitInstanceHeader(appInstanceHeader string) (string, string) {
-	appDetails := strings.Split(appInstanceHeader, ":")
-	return appDetails[0], appDetails[1]
+func validateProcessInstanceHeader(processInstanceHeader string) error {
+	// Regex to match format of `PROCESS_GUID:INSTANCE_ID`
+	//   and to match format of `PROCESS_GUID`
+	r := regexp.MustCompile(`^[\da-f]{8}-([\da-f]{4}-){3}[\da-f]{12}(:\d+)?$`)
+	if !r.MatchString(processInstanceHeader) {
+		return fmt.Errorf("Incorrect %s header : %s", router_http.CfProcessInstance, processInstanceHeader)
+	}
+	return nil
+}
+
+func splitInstanceHeader(instanceHeader string) (string, string) {
+	details := strings.Split(instanceHeader, ":")
path: handlers/lookup.go
user: login: ameowlia
body: done: https://github.com/cloudfoundry/gorouter/pull/467/commits/66256bf7ff41818b4ce86fe72b087bdac2719193
created_at: 2025-03-18T16:39:42Z
author_association: MEMBER

PR comments:
user: login: tcdowney
created_at: 2025-03-17T22:10:37Z
body: What happens if both `X-CF-APP-INSTANCE` and `X-CF-PROCESS-INSTANCE` are provided? It looks like the app instance one takes precedence? Should we keep that behavior or return a 400 + error?
author_association: MEMBER
user: login: tcdowney
created_at: 2025-03-18T15:37:34Z
body: > To make sure I get this right:
> 
> * an application can consist of multiple processes,
> * a process is a "version" of the app and can consist of multiple instances, and
> * an instance is a single instance of a process?

This might be too much information, but I'll start from the beginning because it is a little confusing.

In around 2016 Apps gained the ability to have multiple Processes. What happened was basically the `apps` table in the database was renamed `processes` and a new App concept was created to be a parent of one or more process types. In the v2 APIs, an "app" is actually a `web` process. To make this migration easier, the initial `web` process for an app shares the same GUID as the app.

So every DesiredLRP on Diego is 1:1 with a Process in CAPI. And every Process instance in CAPI is 1:1 with an ActualLRP.
Newer features of CAPI such as rolling and canary deployments now make 

We never updated instance based routing to account for this, but it mostly worked because the app and `web` process GUIDs were the same.

Instance based routing never worked for apps that had multiple process types, but it was a seldom used feature so no one complained. Things changed however once CAPI started supporting rolling and canary deployments. Under the hood these make use of multiple processes running simultaneously -- new Processes in CAPI get a unique GUID that no longer matches the App's GUID. During canary deployments in particular users want to direct traffic to the newly spawned process instances, but discovered it doesn't work because these no longer share the same GUID.

So this change is to introduce a header that acknowledges that multiple Processes can exist for an App and basically replaces `X-CF-APP-INSTANCE` for users that make use of these newer features.

Helpful docs: 
* https://docs.cloudfoundry.org/devguide/multiple-processes.html
* https://docs.cloudfoundry.org/devguide/deploy-apps/rolling-deploy.html#how-deployment-strategies-work
author_association: MEMBER
user: login: ameowlia
created_at: 2025-03-18T16:11:43Z
body: > What happens if both `X-CF-APP-INSTANCE` and `X-CF-PROCESS-INSTANCE` are provided? It looks like the app instance one takes precedence? Should we keep that behavior or return a 400 + error?

@tcdowney You are correct. It will look at `X-CF-APP-INSTANCE`. I wanted to keep it uncomplicated. I plan to document this preference.
LMK if you feel strongly, and I can change it to an error.
author_association: MEMBER
user: login: maxmoehl
created_at: 2025-03-18T16:46:57Z
body: @tcdowney oh wow, thanks for the detailed explanation!
author_association: MEMBER
user: login: ameowlia
created_at: 2025-03-18T19:27:40Z
body: docs PR here: https://github.com/cloudfoundry/docs-dev-guide/pull/549
author_association: MEMBER