Timeout & Fallback

A page is only as reliable as the sources it includes. An upstream API that's slow or down shouldn't block the whole page or surface as broken markup. bext provides four layers of resilience — timeouts, fallback chains, error strategies, and circuit breakers — all declarative on the include.

<bext-include src="/api/live-scores"
  timeout="200ms"
  fallback="/_partials/cached-scores.html"
  fallback-2="/_partials/default-scores.html"
  onerror="continue"
>Scores unavailable</bext-include>

Give up after 200ms; try two fallbacks; silently drop if all fail; show a friendly message if JS is disabled. Every behavior is one attribute.

The layers

Primary src
  ↓ (fails or times out)
fallback  ────── tries another source path
  ↓ (fails)
fallback-2 ───── tries a second source path
  ↓ (fails)
inline content  — uses content between open/close tags
  ↓ (no inline)
onerror strategy
  ├── "continue" → return empty string (include disappears)
  └── "abort"   → leave the <bext-include> tag as-is

Each layer is independent. Set only what you need.

Timeouts

<bext-include src="/api/recommendations"
  timeout="200ms"
></bext-include>
Format Max wait
100ms 100 milliseconds
500ms 500 milliseconds
1s 1 second
5s 5 seconds

When the inner resolver takes longer than the timeout, bext cancels and treats it as a failure — moving to the fallback chain.

Useful timeout ranges:

- Cache / file partials: no timeout needed (resolution is microseconds).

- V8 renders: 200ms-500ms (depends on component complexity).

- Upstream HTTP fetches: 100ms-1s (depends on SLA).

- Database queries: 200ms-500ms.

Default timeout comes from config:

[include.timeouts]
default = "5s"      # global fallback
proxy = "3s"        # for upstream fetches
v8 = "500ms"        # for V8 renders

Per-include timeout attributes override these.

Fallback chain

When the primary source fails or times out, bext tries fallback. If that also fails, fallback-2. Each is a complete src — they can point anywhere: files, endpoints, V8 renders.

<bext-include src="/api/live-prices"
  fallback="/cache/prices/latest"
  fallback-2="/_partials/default-prices.html"
  timeout="100ms"
></bext-include>

- Try /api/live-prices with 100ms timeout.

- On timeout/failure, try /cache/prices/latest.

- On second failure, try /_partials/default-prices.html.

- If everything fails, use inline content or apply onerror.

Fallbacks go through the same resolver chain as the primary — so your fallback can itself be cached, auth-gated, or rendered via V8. They're not special, just alternate paths through the normal pipeline.

Inline fallback

Content between <bext-include> and </bext-include> is the last-resort fallback:

<bext-include src="/api/comments" timeout="500ms">
  <p class="error">Comments couldn't load. <a href="">Retry?</a></p>
</bext-include>

If the primary source and all configured fallbacks fail, the inline content is emitted. This also doubles as the placeholder for loading="lazy" — so one piece of content serves two purposes.

onerror strategies

<!-- Silently drop on total failure -->
<bext-include src="/unknown" onerror="continue"></bext-include>

<!-- Leave the tag as-is on failure (debugging) -->
<bext-include src="/unknown" onerror="abort"></bext-include>
Strategy Behavior
continue Emit empty string; include "disappears" from the HTML
abort (default) Leave the <bext-include> tag as-is in the response

continue is the right default for non-critical includes (sidebars, decorations, recommendations). abort is useful in development where you want to see which includes failed.

ESI's onerror="continue" attribute maps to the same behavior for ESI tags.

Circuit breaker

If a source fails repeatedly, the circuit breaker opens — subsequent requests skip the source entirely and go straight to fallback, without waiting for the timeout.

[include.circuit_breaker]
enabled = true
failure_threshold = 5     # failures before opening
cooldown = "30s"          # time before retrying
half_open_requests = 1    # test requests in half-open state

Circuit states

          ┌─────────────┐
          │   Closed    │  Normal operation.
          │             │  Failures count toward threshold.
          └──────┬──────┘
                 │ N failures
                 ↓
          ┌─────────────┐
          │    Open     │  Skip resolution, go straight to fallback.
          │             │  No timeout overhead.
          └──────┬──────┘
                 │ cooldown expires
                 ↓
          ┌─────────────┐
          │  Half-open  │  Allow one request through.
          │             │  Success → Closed. Failure → Open.
          └─────────────┘

Once the circuit is open, requests don't even try the failing source — they go straight to fallback. This protects both your page (no latency penalty from the 500ms timeout) and the failing upstream (no amplification during an outage).

Per-source isolation

Circuit state is per source path. An open circuit on /api/feed-a doesn't affect /api/feed-b. You can have some sources in Open state while others are healthy.

Manual reset

# Reset a specific source's circuit
curl -X POST http://localhost/__bext/include/circuit/reset \
  -d '{"src":"/api/feed-a"}'

Useful after fixing an upstream issue — forces the circuit back to Closed without waiting for cooldown.

Logging

Every timeout, fallback, and circuit state change is logged:

WARN include timeout: src=/api/recommendations timeout=200ms elapsed=201ms
WARN include fallback: src=/api/recommendations → /cache/recommendations
WARN circuit opened for /api/feed after 5 failures (cooldown 30s)
INFO circuit closed for /api/feed after successful recovery

These feed into bext's observability stack — you can alert on open circuits, track fallback rates, and measure include-level SLA violations.

Practical examples

Upstream API with aggressive caching fallback

<bext-include src="/api/leaderboard"
  timeout="150ms"
  fallback="/_partials/leaderboard-cache.html"
  ttl="30s"
  tags="leaderboard"
></bext-include>

Fresh data from the API with 150ms budget; fall back to a pre-generated cache file on slow responses. Cache the result for 30s so the next N viewers get the fast path.

Non-critical decoration

<bext-include src="/api/weather-widget"
  timeout="100ms"
  onerror="continue"
></bext-include>

Weather is nice to show but not critical. 100ms budget; if the API is down, the widget just doesn't appear.

Financial data with strict SLA

<bext-include src="/api/live-price"
  timeout="50ms"
  fallback="/api/last-known-price"
  fallback-2="/_partials/price-unavailable.html"
>
  <div class="price-placeholder">—</div>
</bext-include>

50ms hard budget (SLA). Fallback to last-known-price. If even that fails, show "price unavailable". Inline placeholder shows a dash if everything fails.

Render with timeout protection

<bext-include src="./components/HeavyChart.tsx"
  render="v8"
  props='{"data":"..."}'
  timeout="300ms"
  fallback="/_partials/chart-fallback.html"
></bext-include>

V8 render with a 300ms budget. If the component takes too long, fall back to a pre-rendered static chart.

See also

- Streaming includes — don't block the shell on slow includes

- Caching includes — cache is the fastest fallback

- File partials — static files as reliable fallback sources

- Observability — monitoring include SLAs