Changelog v3.18.0
Version 3.18.0 (2026-06-23)
The headline of this release is HTTP Custom Type response caching — outbound HTTP calls made by HTTP Custom Types can now be cached and reused, eliminating repeated calls to the same upstream within a configurable time window. The release also fixes a duplicate-outbound-call bug for HTTP types on database-function endpoints.
New Features
HTTP Custom Type response caching — @cache directive
An HTTP Custom Type can now opt into response caching with a @cache directive in its type comment, alongside the existing @timeout and @retry_delay directives. Directives appear before the request line:
sql
comment on type books_api is '@cache 5m
GET https://books.toscrape.com/';A cached type fires one outbound call for a given request shape; subsequent matching requests are served from the in-memory cache until the TTL elapses. For a type with no per-request placeholders (a constant URL/headers/body), that means a single shared upstream call per TTL window across the whole application — instead of one call per inbound request.
Behavior and safety rules:
- Opt-in, GET-only. Caching is enabled per type by
@cache. A@cachedirective on any non-GET method is ignored with a startup warning — caching a mutating call is almost always a mistake. - TTL.
@cache <interval>accepts the same formats as@timeout(5m,30s,1h,00:05:00, or a bare number of seconds). A bare@cache(no interval) caches with no expiration (until the process restarts) and warns. - Success-only. Only successful (2xx) responses are cached, so a transient upstream failure is never pinned for the whole TTL — the next request re-fetches.
- Stampede protection. A burst of concurrent requests for the same cache key coalesces into a single outbound call; the rest await the in-flight result (same
Lazy<Task>coalescing model as the routine cache). - Cache key = HTTP method + resolved URL + resolved content-type + resolved headers + resolved body. Placeholders are resolved first, so per-request values vary the key naturally.
Configuration (HttpClientOptions):
CacheEnabled(defaulttrue) — global kill switch. Whenfalse,@cachedirectives are ignored and every request fires a fresh call.MaxCacheEntries(default10000) — bounds memory; once full, new responses are not cached (existing entries still serve and expire normally).CachePruneIntervalSeconds(default60) — how often expired entries are pruned.
Fixes
HTTP Custom Type request fired once per composite field on database-function endpoints
An endpoint backed by a database function/procedure whose parameter is an HTTP Custom Type fired one outbound HTTP call per field of the type on every inbound request (a 4-field type → 4 identical calls; a 6-field type → 6), multiplying latency and load on the target. SQL-file endpoints were not affected.
Cause. A composite function parameter is expanded into one parameter per field, each carrying the same TypeDescriptor.CustomType (the HTTP type name). The per-request list of HTTP types therefore held the same name N times, and the firing loop in HttpClientTypeHandler.InvokeAllAsync called InvokeAsync once per entry — while the fill loop immediately below resolves handlers by distinct type name. The design already assumes one call per distinct type; the firing loop just failed to match.
Fix. A guard in the firing loop requests each distinct HTTP type once, reusing the dictionary the fill loop already keys on. The established contract is preserved: one call per distinct HTTP type, shared from one response — two parameters referencing the same type still share a single call, and two different types remain two separate calls.
HTTP type directives after the headers were silently ignored
The @timeout, @retry_delay, and @cache directives are now parsed both before the request line and after the headers. Previously only the leading position (before the request line) was recognized, so a directive placed after the headers — as the documentation and examples showed — was silently dropped (e.g. a @timeout that never applied). Both placements are now equivalent. Real HTTP headers are unaffected: a header whose name merely starts with a directive keyword (e.g. Cache-Control) is still treated as a header.
Tests
- Regression tests count actual outbound calls via WireMock response callbacks (the prior suite asserted content but never call counts): a 6-field type fires exactly one call (was 6), and two distinct types fire one call each.
- Caching tests cover: cache hit reduces to one call, 6-field dedup + caching combined, error responses not cached,
@cacheignored on POST, and TTL expiry. Parse-level tests cover the@cachedirective forms and GET-only enforcement.