Skip to content
AI-assisted, verified against source

Changelog v3.18.0

Version 3.18.0 (2026-06-23)

Full Changelog

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
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 @cache directive 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 (default true) — global kill switch. When false, @cache directives are ignored and every request fires a fresh call.
  • MaxCacheEntries (default 10000) — bounds memory; once full, new responses are not cached (existing entries still serve and expire normally).
  • CachePruneIntervalSeconds (default 60) — 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, @cache ignored on POST, and TTL expiry. Parse-level tests cover the @cache directive forms and GET-only enforcement.

Comments