Skip to content

Kontent.Ai.Delivery 20.0.0-rc.1

Pre-release
Pre-release

Choose a tag to compare

Targets .NET 10. Every package in this product moves from net8.0 to net10.0, which is why this is a major release, and Refit's transport is upgraded across four major versions. Beyond the target framework the public API is almost untouched — one configuration hook is removed, and two request-building details change in ways that are visible in logs but not in results.

Breaking changes

  • net8.0net10.0. There is no multi-targeting, so a project on .NET 8 cannot install this release at all — restore fails with NU1202: Package Kontent.Ai.Delivery is not compatible with net8.0. Move to .NET 10 first. Kontent.Ai.Delivery.SourceGeneration is the exception and stays netstandard2.0, as Roslyn components must; it loads in every SDK from .NET 8.0.2xx onward, unchanged.

  • The client interfaces no longer carry IDisposable / IAsyncDisposable; the concrete clients do. Disposal exists for one situation - a client built outside a container, which owns its own transport and must release it. Putting it on the interface meant every consumer holding IDeliveryClient was offered a Dispose() that, on the container path, released nothing and must not be called: the container owns that lifetime. DeliveryClientBuilder.Build() now returns the concrete DeliveryClient, which is IDisposable and IAsyncDisposable, so disposal stays available exactly where it means something.

    await using var client = DeliveryClientBuilder…Build(); is unchanged, and so is every DI usage. The only code that breaks widened the builder result to the interface and then disposed it:

    // Before - no longer compiles, because the interface has no Dispose
    IDeliveryClient client = DeliveryClientBuilderBuild();
    client.Dispose();
    
    // After - keep the concrete type, or just use var
    var client = DeliveryClientBuilderBuild();
    client.Dispose();

    Container-resolved clients are still disposed by the container, which checks the runtime type rather than the registered service type - so nothing changes there.

  • The configureRefit parameter is gone from all six AddDeliveryClient overloads, and RefitSettingsProvider is now internal. The hook exposed the transport library's settings object, but everything reachable through it was load-bearing rather than configurable: the parameter-key formatter is what matches the API's casing, and the serializer options carry the converters and nesting limit the wire format requires. Overriding any of them broke requests silently. If you passed configureRefit, delete the argument — the SDK's own tests never used it for anything but asserting the callback fired. Should a real need surface, it will return as an API named for what it does rather than for the transport library.

Changed

  • Registering from IConfiguration can now customize the HTTP client and the resilience pipeline. The configuration-based AddDeliveryClient overloads took no configureHttpClient / configureResilience, so binding options from configuration and replacing the retry pipeline were mutually exclusive. The workaround — binding by hand inside an Action<DeliveryOptions> — compiles and looks equivalent, but registers no change token, so IOptionsMonitor silently stops reloading. Both hooks are now available on every configuration overload, alongside the ones that already had them.
  • AddDeliveryClient gained the overloads its sibling SDKs already had, so the three register a client the same way: named IConfiguration and IConfigurationSection registration. Nothing was removed, and existing calls are unaffected — this closes gaps rather than reshaping the surface.
  • CacheResult<T> carries FromFactory, saying whether the factory produced the value during this call or the cache served a stored one. Nothing else about the type changes and the constructor is untouched, so existing code compiles unchanged. A custom IDeliveryCacheManager that builds its own CacheResult<T> should set it — left at its false default, every result it returns is treated as a cache hit.
  • DeliveryOptions.DefaultConfigurationSectionName exposes the section name the configuration overloads bind by default, matching ManagementOptions. Design-time tools that resolve the SDK's configuration from the same sources can probe it instead of hard-coding the string.
  • Repeated filter parameters are emitted in declaration order. Filters sharing an element used to be grouped together regardless of where they appeared, so a, b, a was sent as a, a, b. Results are unaffected — filter parameters are AND-ed, and cache keys were already order-independent — but the request URL differs, which shows up in logs and traces.
  • Cancellation now throws; other transport failures are results. Refit's upgrade changed the
    contract: exceptions raised in the HTTP pipeline are captured into the response rather than thrown.
    A network failure, DNS failure or resilience-pipeline rejection is therefore an unsuccessful result
    carrying the exception, consistent with how every other failure in this SDK is reported. Cancellation
    is the exception to that: when the caller's token fires, the OperationCanceledException is rethrown,
    so Task.IsCanceled, Task.WhenAll and cancellation handlers behave as they do everywhere else in
    .NET. Previously all of these threw. An expired HttpClient.Timeout is not cancellation, even
    though .NET surfaces it as a TaskCanceledException: the request was sent, so it is reported as a
    failed result like any other transport failure.
  • Transport failures report status 0. the result object now carries (HttpStatusCode)0 for that case rather than an invented code. Responses that did arrive are unaffected.

Fixed

  • An element the model cannot map is now logged as a warning rather than at debug level. When a value fails to deserialize onto the generated property, the SDK logs and yields null — but null is also what an empty element gives, so the log is the only thing distinguishing the two. At Debug it was absent from any normal production configuration, which made a model that had drifted from the content type look like missing content instead of a mismatch. The behaviour is unchanged; only the level is.

  • Cache invalidation propagates reliably between nodes once a backplane is registered. With AddDeliveryHybridCache, part of the invalidation state is held per IDeliveryCacheManager instance, so whether one node observes another's invalidation depends on the order the two nodes happened to read and invalidate in. In one measured ordering — node A caches an entry, node B reads it, then A invalidates — B keeps serving the evicted content until the entry expires on its own. Register an IFusionCacheBackplane and the SDK now wires it up, so invalidations propagate regardless of ordering:

    services.AddStackExchangeRedisCache(o => o.Configuration = "localhost");
    services.AddFusionCacheStackExchangeRedisBackplane(o => o.Configuration = "localhost");
    services.AddDeliveryHybridCache();

    Nothing changes for a single-instance application, which needs no backplane. FusionCache keeps an in-memory tier in front of the distributed one and uses it either way; the backplane is what keeps those tiers in step across nodes, so without one an invalidation still reaches only the node that performed it.

  • Cache invalidation no longer skips items whose codename looks like a component's. Components were told apart by the shape of their generated codename — a _-separated group of four characters starting 01, as in n373888cc_34e2_01e1_1820_3cb52ab1b2a1. Authored codenames collide with that: Product SKU 0123 Blue becomes product_sku_0123_blue, whose third group is 0123. Such an item was silently given no item_ dependency key, so a webhook naming it evicted nothing and the cached response kept being served until it expired — the failure was invisible and depended on how content was named.

    Components are now recognised from the response instead: the Delivery API gives every content item a workflow and workflow_step and gives components neither. Where that signal is not available the item is tracked regardless, because the two mistakes are not equal — a dependency key for a component is one entry nobody ever looks up, while a missing key for an item is stale content.

  • Eager refresh no longer misreports what a query returned. With EagerRefreshThreshold set, the cache returns the stale-but-valid value immediately and refreshes it on a background thread. Each query builder decided whether it was serving a cache hit by reading variables its own cache factory wrote into — and the background refresh writes into those same variables, for a different call. Depending on how the two threads interleaved, an ordinary eager-refresh hit could come back as ResponseSource.FailSafe, or be logged as a fresh fetch and wrapped with the background request's status code. The decision now comes from the cache, which is the only component that knows which value it handed back. Consumers who leave EagerRefreshThreshold at its default of 0 were never affected.

  • Rich-text parsing and resolution no longer risk deadlocking a caller that blocks on the task. Every await in the SDK opts out of the caller's synchronization context — except the rich-text subsystem, which had drifted: 17 awaits across the parser, the HTML resolver and the default resolvers captured the context. On a host that has one (WPF, WinForms, legacy ASP.NET), code that blocks on the returned task — .Result, .Wait() — could deadlock. ASP.NET Core has no synchronization context and was never affected. CA2007 is now enforced across the SDK libraries so this cannot drift again.

  • The configured retry pipeline can now run to completion. HttpClient's 100-second default bounds the whole call, retries and backoff included, and nothing raised it — so a pipeline allowed four 30-second attempts plus exponential backoff was silently cut off partway through the last one. The SDK's resilience pipeline already bounds each attempt, so it now owns timing outright and the transport-level ceiling is removed. Requests still stop when your CancellationToken fires.

  • Long-running applications pick up DNS changes instead of pinning the address resolved at startup. The registered client is a singleton and takes its HttpClient from IHttpClientFactory once, so the handler chain it holds was never rotated — the factory only hands a fresh chain to a new CreateClient call. Connections now recycle every two minutes, matching the factory's own default handler lifetime. This matters when the endpoint's address changes underneath a process that stays up for days: a failover, a scale event, or any CDN re-pointing. Configuring your own primary handler via configureHttpClient still overrides this, as before.

  • Retried requests no longer accumulate duplicate X-KC-SDKID headers. The tracking handler sits below the resilience handler, so every retry re-runs it against the same request message. The header was appended rather than replaced, adding one duplicate value per attempt. Writes are now idempotent. Only the outgoing request differed; results were unaffected.

  • A failure resolving the X-KC-SOURCE header no longer breaks every later request. The value is cached in a Lazy<string?>, and the resolution walks the call stack to attribute the calling package. An exception thrown during that walk was cached alongside the value and rethrown on every subsequent request for the lifetime of the process. Resolution failures are now contained and the header simply omitted, which was the intent.

  • Filter codenames are normalized to lower case, so one query no longer occupies two cache entries. The Delivery API is case-insensitive here, so System.Codename[EQ] and system.codename[eq] always returned the same items — but the SDK hashed the filter key verbatim when building cache keys, so the two spellings cached separately, doubled origin calls, and invalidated independently. Codenames are lower case by construction, so this can only change input that was already unconventional.

  • An empty pre-release label no longer produces a trailing hyphen in X-KC-SOURCE. A package identifying itself with [assembly: DeliverySourceTrackingHeader("MyPackage", 2, 0, 0, "")] was reported as MyPackage;2.0.0-, which is not a valid SemVer version. An empty label now counts as no label, matching what passing null already did.

  • HTTP responses are released as soon as they are mapped, instead of waiting for finalization. Every query turned its Refit response into a result without disposing it, so each HttpResponseMessage and its buffered content stayed alive until the garbage collector ran the finalizer. Refit reads the body in full, so connections still returned to the pool and no request ever failed — the cost was memory pressure that grew with throughput, and it was heaviest where responses come in volume, such as enumerating a whole project a page at a time. The Management and Sync SDKs already disposed here; Delivery now matches them.

Dependencies

Shipped floors on Kontent.Ai.Delivery, Kontent.Ai.Delivery.Caching and Kontent.Ai.Urls moved up. All are .NET 10 aligned:

  • Microsoft.Extensions.* (Configuration, .Binder, Options, .ConfigurationExtensions, .DataAnnotations, Primitives, Logging.Abstractions) 9.0.1510.0.10.
  • Microsoft.Extensions.Http.Resilience 9.6.010.8.0.
  • Microsoft.Extensions.Caching.Abstractions 8.0.0 and .Caching.Memory 10.0.610.0.10; .Caching.StackExchangeRedis 8.0.2610.0.10 (Kontent.Ai.Delivery.Caching).
  • AngleSharp 1.5.01.7.0.
  • ZiggyCreatures.FusionCache and its System.Text.Json serializer 2.5.02.6.0 (Kontent.Ai.Delivery.Caching).
  • Refit and Refit.HttpClientFactory 10.2.014.0.1.

Kontent.Ai.Delivery.Abstractions ships no package dependencies of its own and is unaffected.

Internal

No consumer-visible effect:

  • Refit 14 builds request logic at compile time instead of by reflection. The filter DSL, whose parameter names are only known at runtime, now renders its own query string and applies it through a message handler, so every operation compiles to generated code and no reflection package is needed. The escaping this produces is byte-for-byte what the previous transport emitted, pinned by a characterization test suite covering reserved characters, pre-encoded input, non-ASCII values, empty operators and repeated keys.
  • A client built by DeliveryClientBuilder now owns its service provider directly, rather than being handed back inside a wrapper whose only jobs were to forward every IDeliveryClient member and dispose the provider behind it. Both entry points construct through one factory, so the container path and the builder path cannot drift; the builder passes the provider as the resource its client owns. Disposal behaves exactly as before - disposing a built client tears down its provider and everything registered in it, including the cache manager and anything added via ConfigureServices, and disposing a container-resolved client still releases nothing, because the container owns its transport.
  • Kontent.Ai.Delivery.SourceGeneration deliberately compiles against an older Roslyn than the rest of the repo. That reference sets the oldest compiler able to load the generator, and a newer one would be skipped silently on older SDKs.

Installation

dotnet add package Kontent.Ai.Delivery --prerelease

Full changelog: src/delivery/CHANGELOG.md