Mert Özen Further With Every Line
Backend API Gateway and YARP — #08

YARP's Release History: What Changed from 1.0 to 2.3 and Reading Old Configurations

Mert Özen Nov 14, 2026 11 min 16 views
YARP's Release History: What Changed from 1.0 to 2.3 and Reading Old Configurations

YARP 2.3 (February 2025) is still the latest release in November 2026; array routes and old policy names from pre-1.0 configs stop the app from starting, but old fields such as HttpRequest.Timeout are silently ignored. We collect the defaults that changed from 1.0 to 2.3 from the release notes, read an old config with 2.3 and its JSON schema, and move an Ocelot-based gateway to YARP, comparing both sides with the same requests.

Version note: The code in this post was built and run with .NET 10, Yarp.ReverseProxy 2.3 and Ocelot 25.0; release information comes from YARP's GitHub release notes.

Guess First

You have a YARP configuration left over from before 1.0. Routes are an array, each with a RouteId field; the cluster says "LoadBalancingPolicy": "First" and "HttpRequest": { "Timeout": "00:00:05" }. When you run this file with YARP 2.3, which of the three old settings stop the app from starting, and which one is silently ignored?

The Road to 1.0: Previews and Renames

YARP's first preview shipped in June 2020 under the name Microsoft.ReverseProxy. Twelve previews and a release candidate came out before 1.0, and the configuration model changed several times along the way. When you read a YARP sample found online or in an old repo, this period's names cause the most confusion. The important changes I collected from the GitHub release notes:

OldNewVersion
BackendsClusters1.0 preview 2 (2020-06)
Host in route matchHosts (list)1.0 preview 3 (2020-07)
Microsoft.ReverseProxy packageYarp.ReverseProxy1.0 preview 10 (2021-03)
Routes as an array with a RouteId fieldRoutes as a dictionary keyed by route ID1.0 preview 11 (2021-04)
ProxyRoute, Cluster, DestinationRouteConfig, ClusterConfig, DestinationConfig1.0 preview 12 (2021-06)
IHttpProxyIHttpForwarder1.0 preview 12
First load balancing policyFirstAlphabetical1.0 preview 12
HttpRequest.TimeoutHttpRequest.ActivityTimeout1.0 RC 1 (2021-10)

The ActivityTimeout change isn't just a rename. The old Timeout was the request's total time; ActivityTimeout restarts on every data transfer. The long-lived connection behavior we saw in post 03 and the WebSocket keep-alive note in post 04 come from this change. 1.0 shipped on 9 November 2021 with support for .NET Core 3.1, .NET 5 and .NET 6. In April 2022, 1.0.1 was released because of a security vulnerability (CVE-2022-26924); if an installation is still on 1.0.0, upgrading it is the first job.

1.1 and 2.0: The Pieces We Use Today

Some of the APIs we used earlier in the series came with 1.1. The 1.1 RC added IProxyStateLookup and ReassignProxyRequest; we built the canary in post 06 with them. The same RC made it necessary to give SocketsHttpHandler an ActivityHeadersPropagator for direct forwarding; that's why InstitutionForwarding in post 05 has that line. 1.1 itself shipped in May 2022 and brought HTTP/3 support.

2.0 (February 2023) dropped support for .NET Core 3.1 and .NET 5 and changed two defaults. The default session affinity policy moved from Cookie to HashCookie. According to the release notes, HashCookie gives weaker guarantees about protecting the cookie value, but lets multiple YARP instances share the same affinity cookie without configuring Data Protection; that's what we used in post 03. YARP also started setting SocketsHttpHandler.ConnectTimeout by default; when a destination doesn't answer, you may see different exceptions in the logs. On the feature side there were WebSockets over HTTP/2, RateLimiterPolicy and MaxRequestBodySize in route config, a built-in InMemoryConfigProvider (the LoadFromMemory we used in post 02), and request transforms that can answer a request without proxying it. In June 2023, 2.0.1 was released for another security vulnerability (CVE-2023-33141).

2.1, 2.2 and 2.3

2.1 (November 2023) arrived together with .NET 8. Its breaking change is the cluster's default available destinations policy moving from HealthyAndUnknown to HealthyOrPanic: even if every destination is unhealthy, YARP still distributes requests. We wrote HealthyOrPanic explicitly in post 03, but it's been the default since 2.1. The same release added TimeProvider support, IDestinationResolver (the foundation for service discovery), the .NET 8 request timeouts integration (the TimeoutPolicy in post 04), YARP's own activity for distributed tracing (the proxy.forwarder span we saw in post 07), and direct forwarding shortcuts such as MapForwarder.

2.2 (September 2024) added OutputCachePolicy to routes. One of its breaking changes relates to what bit us in post 04: errors caused by the client disconnecting (RequestCanceled and similar) are now logged at Debug level. That's why we couldn't see the request timeout returning 400 in the log. Also, the NotExists header match mode started matching headers with empty values, and active health checks now treat newly added destinations as Unknown rather than Healthy until the first probes complete.

2.3 (27 February 2025) is still the latest release today. According to its release notes, it supports .NET 8 and later. It brought a JSON schema for configuration, moved the repo from the microsoft organization to the dotnet organization, moved the documentation to Microsoft Learn, and started publishing the minimal container image we inspected in post 07. The most important breaking change is that HttpVersionPolicy is now always enforced: previously, protocol upgrade requests such as WebSockets could open an HTTP/1.1 connection even if the policy didn't allow it. Replacing HttpRequestMessage.Content in a request transform now throws an exception; that's another reason we said "don't touch the request body" in post 02.

As of November 2026 there's no NuGet release newer than 2.3.0. The main branch on GitHub is versioned 3.0.0-preview.1 and was moved to .NET 10 on 3 November 2026, but no 3.0 package has been published. The container image's tags are still 2.3 previews too. When 3.0 ships, we'll update this post with its release notes; until then, everything built on 2.3 works as it does in this series.

Reading an Old YARP Configuration

I ran the file from "Guess First" with this post's sample (samples/Bookstore.Gateway.FromOcelot). The sample reads the file named in its ProxyConfig setting and has a small endpoint that shows what YARP understood from the config:

// what YARP actually made of the configuration: useful when reading an old config file
app.MapGet("/debug/routes", (IProxyStateLookup proxyState) => proxyState.GetRoutes().Select(route => new
{
    route.Config.RouteId,
    route.Config.Match.Path,
    route.Config.ClusterId,
    route.Config.Order,
    ActivityTimeout = route.Cluster?.Model.Config.HttpRequest?.ActivityTimeout?.ToString("c", CultureInfo.InvariantCulture),
    route.Cluster?.Model.Config.LoadBalancingPolicy,
}));
{
  "ReverseProxy": {
    "Routes": [
      {
        "RouteId": "books",
        "ClusterId": "catalog",
        "Match": {
          "Path": "/api/v1/books/{**everything}"
        },
        "Transforms": [
          { "PathPattern": "/books/{**everything}" }
        ]
      }
    ],
    "Clusters": {
      "catalog": {
        "LoadBalancingPolicy": "First",
        "HttpRequest": {
          "Timeout": "00:00:05"
        },
        "Destinations": {
          "catalog-1": { "Address": "http://localhost:5301/" }
        }
      }
    }
  }
}

In three steps, fixing one problem each time:

dotnet run --project samples/Bookstore.Gateway.FromOcelot -- --ProxyConfig=legacy-yarp.json

# 1) the file as is
fail: Yarp.ReverseProxy.Configuration.ConfigProvider.ConfigurationConfigProvider[47]
      Configuration data conversion failed.
      System.Exception: The route config format has changed, routes are now objects instead of an array. The route id must be set as the object name, not with the 'RouteId' field.
Unhandled exception. System.InvalidOperationException: Unable to load or apply the proxy configuration.

# 2) Routes turned into a dictionary
Unhandled exception. System.InvalidOperationException: Unable to load or apply the proxy configuration.
 ---> System.ArgumentException: No matching ILoadBalancingPolicy found for the load balancing policy 'First' set on the cluster 'catalog'.

# 3) First -> FirstAlphabetical
curl http://localhost:5306/debug/routes
[{"routeId":"books","path":"/api/v1/books/{**everything}","clusterId":"catalog","order":null,"activityTimeout":null,"loadBalancingPolicy":"FirstAlphabetical"}]
books 200

The answer to "Guess First": the first two stopped the app from starting, while Timeout was silently ignored. YARP recognizes the old array format and gives an explanatory error; it also catches the unknown policy name during config validation. But it never reads a field it doesn't know: activityTimeout is empty, so the request will be cut after the default 100 seconds, not 5. The most dangerous mistakes when moving an old configuration are the ones that don't fail. This is where 2.3's JSON schema helps. I ran the schema against two files with a JSON Schema validator (Python jsonschema):

legacy-yarp.json -> 2 error(s)
   ReverseProxy/Clusters/catalog/HttpRequest : Additional properties are not allowed ('Timeout' was unexpected)
   ReverseProxy/Routes : [{'RouteId': 'books', 'ClusterId': 'catalog', 'Match': {'Path': '/api/v1/books/{**everything}'}, 'Transforms': [{'PathPa
yarp.json -> 0 error(s)

According to the release notes, once you reference the 2.3 package, the editor validates the ReverseProxy section in appsettings.json with this schema on its own; if the config lives in another file, or you want to check it in CI, you can run the same schema through a validator. For LoadBalancingPolicy, though, the schema allows any string besides the known names, because you can write your own policy; a mistake like First only shows up when the app starts.

From Ocelot to YARP: Mapping the Concepts

Ocelot was the most widely used open source API gateway project in .NET before YARP, and it's still being developed. Say Bookstore's old gateway was written with Ocelot; in the repo, samples/Bookstore.Gateway.Ocelot is that "before" state, running on Ocelot 25.0. It has two routes: anonymous book reads and an order route that requires a token and is rate limited:

{
  "Routes": [
    {
      "UpstreamPathTemplate": "/api/v1/books/{everything}",
      "UpstreamHttpMethod": [ "Get" ],
      "DownstreamPathTemplate": "/books/{everything}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        { "Host": "localhost", "Port": 5301 },
        { "Host": "localhost", "Port": 5311 }
      ],
      "LoadBalancerOptions": { "Type": "RoundRobin" }
    },
    {
      "UpstreamPathTemplate": "/api/v1/orders/{everything}",
      "UpstreamHttpMethod": [ "Get" ],
      "DownstreamPathTemplate": "/orders/{everything}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        { "Host": "localhost", "Port": 5302 }
      ],
      "AuthenticationOptions": { "AuthenticationProviderKeys": [ "Bearer" ] },
      "RateLimitOptions": {
        "ClientIdHeader": "X-Client-Id",
        "EnableRateLimiting": true,
        "Period": "10s",
        "Limit": 5
      }
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": "http://localhost:5308"
  }
}

Most concepts have a direct counterpart, but a few move to a different layer:

OcelotYARP
UpstreamPathTemplate, UpstreamHttpMethodMatch.Path, Match.Methods on the route
DownstreamPathTemplateA PathPattern or PathRemovePrefix transform
DownstreamHostAndPorts, DownstreamSchemeDestinations and Address on the cluster
LoadBalancerOptions.TypeLoadBalancingPolicy (RoundRobin, LeastRequests…); session affinity for sticky sessions
Priority (higher value first)Order (lower value first)
AuthenticationOptions, RouteClaimsRequirementASP.NET Core authentication and AuthorizationPolicy
RateLimitOptionsThe ASP.NET Core rate limiter and RateLimiterPolicy
Route cache settingsASP.NET Core output caching and OutputCachePolicy
AggregatesYour own endpoint (the aggregation in post 05)
Service discovery providersIDestinationResolver (post 03)

The general line is this: Ocelot handles authentication, rate limiting and caching with its own configuration schema; YARP leaves them to ASP.NET Core's own middleware and keeps only the policy name on the route (post 04). Migrating means less translating Ocelot's JSON into YARP's JSON and more rewriting the behaviors inside Ocelot as ASP.NET Core policies. The YARP counterpart (samples/Bookstore.Gateway.FromOcelot/yarp.json):

"Routes": {
  "books": {
    "ClusterId": "catalog",
    "Match": {
      "Path": "/api/v1/books/{**everything}",
      "Methods": [ "GET" ]
    },
    "Transforms": [
      { "PathPattern": "/books/{**everything}" }
    ]
  },
  "orders": {
    "ClusterId": "ordering",
    "AuthorizationPolicy": "Default",
    "RateLimiterPolicy": "per-user",
    "Match": {
      "Path": "/api/v1/orders/{**everything}",
      "Methods": [ "GET" ]
    },
    "Transforms": [
      { "PathPattern": "/orders/{**everything}" }
    ]
  }
},
"Clusters": {
  "catalog": {
    "LoadBalancingPolicy": "RoundRobin",
    "Destinations": {
      "catalog-1": { "Address": "http://localhost:5301/" },
      "catalog-2": { "Address": "http://localhost:5311/" }
    }
  },
  "ordering": {
    "Destinations": {
      "ordering": { "Address": "http://localhost:5302/" }
    }
  }
}
// Ocelot's RateLimitOptions counted per X-Client-Id header; here the partition is the validated user
builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("per-user", context => RateLimitPartition.GetFixedWindowLimiter(
        context.User.FindFirstValue("sub") ?? context.Connection.RemoteIpAddress?.ToString() ?? "anonymous",
        _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromSeconds(10), QueueLimit = 0 }));
});

To check that the migration is right, I sent the same set of requests to both gateways and put the results side by side; Ocelot (5308) on the left, YARP (5307) on the right:

                                        Ocelot :5308                 YARP :5307
books/9780441172719 x4                  catalog-1 2 1 2              catalog-2 1 2 1
BOOKS/9780441172719                     404                          200
orders/1001 no token                    503                          401
orders/1001 Ada                         503                          200
orders/1001 Ada, X-Client-Id: web x7    200 200 200 200 200 429 429  200 200 200 429 429 429 429

Each difference is a migration decision. The uppercase URL didn't match in Ocelot 25.0 but did in YARP; Ocelot has a RouteIsCaseSensitive setting that controls this per route. If you switch without testing both gateways against the same list of URLs, a client getting a 404 today may get a 200 tomorrow, or the other way around. The biggest difference is in rate limiting. Ocelot identifies the client from the X-Client-Id header and rejects the request with 503 if the header is missing; this check also runs before authentication, which is why even a request without a token got 503 instead of 401:

Rate limiting client could not be identified for the route '/api/v1/orders/{everything}' due to a missing or unknown client ID header required by rule '5/10s/w10s'!

On the YARP side the partition is the validated user, so no header is needed. The difference in the last row comes from this too: YARP counted Ada's two earlier requests in the same window, so it returned 429 after the third request. Which one is right is a product decision; but limiting by a header the client sends means the client can escape the limit by changing the header. We discussed this in post 04.

Production Notes

Before upgrading YARP, read the "Breaking changes" section of every release in between; the tables in this post came from those sections. Do the upgrade in a test environment and compare the route table before and after the upgrade with an endpoint like /debug/routes. Silent changes (an ignored field, a changed default) only show up in that comparison.

Track runtime support separately from YARP support. 2.3 was released for .NET 8 and later, but .NET 8 support ended on 10 November 2026; to stay on a supported combination, use .NET 10 as this series does. Because the YARP package carries a net8.0 target, it runs without problems in a .NET 10 app.

Don't migrate from Ocelot in one go. The strangler approach from post 06 applies to the gateway itself: put YARP in front of or next to Ocelot, move routes one at a time, and compare both sides with the same request set at every step as above. Leave routes whose rate limiting or authentication behavior differs until last, and tell clients in advance.

Common Pitfalls

First, copying an old sample from the internet. If you see IHttpProxy, ProxyRoute or Routes as an array, the sample is from before 1.0; on 2.3 it won't compile or won't run.

Second, trusting config that doesn't fail. Unknown fields are ignored; add schema validation to CI.

Third, forgetting that defaults changed. An installation from before 2.1 ran with HealthyAndUnknown; after upgrading, when every destination is unhealthy, traffic is distributed instead of being cut off.

Fourth, copying Ocelot's Priority straight into YARP's Order. According to Ocelot's documentation, the higher value matches first in Ocelot; in YARP, the lower value does. If you move the numbers without inverting them, you fall into the catch-all trap from post 02.

Fifth, assuming Ocelot's rate limiting and authentication behave exactly the same. The table above shows that two configs that look the same can make the difference between 503 and 200 for the same request.

A Small Experiment

★☆☆ Change Timeout in legacy-yarp.json to ActivityTimeout and confirm that activityTimeout is 00:00:05 in the /debug/routes output. Run the schema validation again; how many errors are left?

★★☆ Add a /api/v1/books/summary route with Priority 1 and a catch-all with Priority 0 to ocelot.json. Build the same behavior in YARP with Order, and show that the results match by sending the same requests to both gateways.

★★★ You have to keep Ocelot's X-Client-Id-based limit because some partners don't use tokens. In YARP, write a rate limiter policy that validates X-Client-Id against an API key list only the gateway knows and partitions by the validated client; unknown clients get 401. Also explain how it fits with the internal header rule from post 04.

This post ends the API Gateway and YARP series. Bookstore.Gateway is now a gateway that starts with the route and cluster model and reaches health checks, identity, rate limiting, a BFF, the strangler fig and production operations. We'll put the gateway in front of Bookstore's microservices in the upcoming .NET Microservices Application series, and define YARP in code inside Aspire in the upcoming Distributed Apps with Aspire series.