07 - Resilience Patterns via the Mesh
Why this matters
Real dependencies fail intermittently — a Pod restarts, a network blip drops a packet, one slow instance drags down every caller. Without the mesh you'd bake retry/timeout logic into every app's HTTP client, differently in each language. The mesh moves retries, timeouts, and circuit breaking into configuration on the sidecars, uniform across every service and language, and — its unique gift — lets you inject failures on demand to prove those policies work before real users find the gaps. That fault-injection idea is the on-ramp to the deliberate failure testing you'll do at cluster scale in track 22.
Concepts
Timeouts: bounding how long a caller waits
A timeout caps how long a caller's sidecar waits for a response before
giving up and returning an error. Without one, a single hung dependency ties
up the caller's resources indefinitely and the stall cascades upward. In Istio
a timeout is a field on a VirtualService route (spec.http[].timeout), so
it's set per-route, in config, not in each app's client library. The
discipline is the same you'd apply anywhere — fail fast rather than hang — but
now it's uniform and adjustable without redeploying code.
Retries: absorbing transient failures
A retry re-sends a failed request, on the theory that many failures are
transient (a Pod mid-restart, a brief network drop). Istio configures retries
on a VirtualService route: how many attempts, a per-try timeout, and which
conditions are retryable (e.g. 5xx, connect-failure, reset). Two
cautions the mesh makes explicit: only retry idempotent operations (a
retried POST can double-charge a customer), and retries + a total timeout
must be sane together (three retries each waiting the full timeout can blow
your latency budget). Retries turn a flaky dependency into a mostly-invisible
one — but only if applied thoughtfully.
Circuit breaking and outlier detection: stop hammering a sick instance
If a backend instance is failing, retrying harder makes it worse. Circuit
breaking protects the dependency by capping load: Istio's DestinationRule
connection-pool limits bound concurrent connections/requests, and requests
over the limit are rejected fast rather than queued. Outlier detection
(also on the DestinationRule) goes further — it watches per-endpoint error
rates and ejects an unhealthy instance from the load-balancing pool for a
while, so traffic routes only to healthy Pods. This is the mesh doing
automatically, per-endpoint, what a human does manually when they cordon a bad
node — and it's why "one bad Pod" doesn't have to mean "elevated errors for
everyone."
Fault injection: proving resilience instead of hoping
Here's the capability you can't easily get without a mesh: fault injection.
A VirtualService can deliberately introduce delays (add N seconds to a
percentage of requests) or aborts (return an HTTP error code for a
percentage of requests) — without touching the backend. You use this to
test that your timeouts, retries, and circuit breakers actually behave as
designed: inject a 5s delay and confirm your 2s timeout fires; inject 50%
503s and confirm your retries mask them. Injecting failure to verify recovery
is exactly the philosophy of chaos engineering — and this per-request,
app-scoped fault injection is the gentle preview of the cluster- and
infrastructure-level failure experiments you'll run in track 22 (disaster
recovery and chaos engineering). The mesh lets you rehearse "what happens
when this dependency misbehaves?" as a routine, reversible config change.
These compose — and can fight each other
Resilience settings interact, and getting them consistent is the real skill:
- A retry budget that exceeds the timeout means the timeout fires mid-retries and you never get the retries you configured.
- Aggressive retries can defeat circuit breaking by generating exactly the surge of load the breaker exists to prevent — a "retry storm."
- Outlier ejection plus a tiny pool can eject so many endpoints that the healthy remainder gets overwhelmed.
The mesh gives you the knobs; fault injection is how you verify the combined behaviour is what you intended, rather than discovering the interaction in a real incident.
Command reference
| Field / command | What it does | Notes |
|---|---|---|
spec.http[].timeout (VirtualService) |
Max wait before the caller's proxy gives up | e.g. timeout: 2s |
spec.http[].retries.attempts |
Number of retry attempts | Total tries = attempts (Istio counts retries) |
spec.http[].retries.perTryTimeout |
Timeout for each individual attempt | Keep attempts × perTryTimeout ≤ route timeout |
spec.http[].retries.retryOn |
Which failures are retryable | e.g. 5xx,connect-failure,reset |
spec.http[].fault.delay |
Inject latency into a % of requests | fixedDelay: 5s, percentage.value: 100 |
spec.http[].fault.abort |
Return an error code for a % of requests | httpStatus: 503, percentage.value: 50 |
spec.trafficPolicy.connectionPool (DestinationRule) |
Caps concurrent connections/requests (circuit breaking) | tcp.maxConnections, http.http1MaxPendingRequests |
spec.trafficPolicy.outlierDetection (DestinationRule) |
Ejects unhealthy endpoints from the pool | consecutive5xxErrors, baseEjectionTime, maxEjectionPercent |
istioctl proxy-config route <pod> |
Shows the timeouts/retries programmed into the proxy | Ground truth of the effective policy |
Hands-on exercises
Continue in mesh-demo with the meshed frontend/backend app, STRICT mTLS,
and the backend DestinationRule (subsets v1/v2). Reset routing to 100%
v1 if you haven't:
kubectl patch virtualservice backend -n mesh-demo --type=merge -p \
'{"spec":{"http":[{"route":[{"destination":{"host":"backend","subset":"v1"},"weight":100},{"destination":{"host":"backend","subset":"v2"},"weight":0}]}]}}'
1. Inject a delay and watch the baseline hang
Add a 5-second delay to 100% of backend requests, then time a call:
# vs-fault-delay.yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: backend
namespace: mesh-demo
spec:
hosts: [backend]
http:
- fault:
delay:
fixedDelay: 5s
percentage: {value: 100}
route:
- destination: {host: backend, subset: v1}
kubectl apply -f vs-fault-delay.yaml
kubectl exec -n mesh-demo deploy/frontend -- sh -c 'time curl -s -o /dev/null http://backend'
Expected: the call takes ~5 seconds and succeeds — the mesh injected the delay without the backend doing anything. Right now nothing bounds that wait; the caller just hangs for 5s. That's the problem a timeout solves.
2. Add a timeout and prove it fires
Now add a 2-second timeout alongside the 5s injected delay:
# vs-timeout.yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: backend
namespace: mesh-demo
spec:
hosts: [backend]
http:
- fault:
delay:
fixedDelay: 5s
percentage: {value: 100}
timeout: 2s
route:
- destination: {host: backend, subset: v1}
kubectl apply -f vs-timeout.yaml
kubectl exec -n mesh-demo deploy/frontend -- sh -c 'time curl -s -o /dev/null -w "%{http_code}\n" http://backend'
Expected: the call now fails at ~2 seconds with 504 (gateway timeout) —
you've proven the timeout works by injecting a delay longer than it. This
is fault injection's whole point: verify the policy, don't assume it.
3. Inject aborts and prove a retry masks them
Remove the delay; instead abort 50% of requests with 503, and confirm the
un-retried failure rate first:
# vs-fault-abort.yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: backend
namespace: mesh-demo
spec:
hosts: [backend]
http:
- fault:
abort:
httpStatus: 503
percentage: {value: 50}
route:
- destination: {host: backend, subset: v1}
kubectl apply -f vs-fault-abort.yaml
for i in $(seq 1 20); do kubectl exec -n mesh-demo deploy/frontend -- curl -s -o /dev/null -w "%{http_code}\n" http://backend; done | sort | uniq -c
Expected: roughly half 200, half 503 — the injected 50% failure rate, with
no retries yet.
4. Add retries and watch the failures shrink
# vs-retry.yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: backend
namespace: mesh-demo
spec:
hosts: [backend]
http:
- fault:
abort:
httpStatus: 503
percentage: {value: 50}
retries:
attempts: 3
perTryTimeout: 1s
retryOn: "503,reset,connect-failure"
route:
- destination: {host: backend, subset: v1}
kubectl apply -f vs-retry.yaml
for i in $(seq 1 20); do kubectl exec -n mesh-demo deploy/frontend -- curl -s -o /dev/null -w "%{http_code}\n" http://backend; done | sort | uniq -c
Expected: far fewer 503s (each request now gets up to 3 more tries, and with
a 50% independent failure chance most eventually hit a 200). You've proven
the retry policy masks transient failures — measured, not assumed. Note this
only works because the injected abort is retryable (503) and the operation
is safe to repeat.
5. Inspect the effective policy on the proxy
istioctl proxy-config route deploy/frontend -n mesh-demo -o json | grep -iE "timeout|retr" | head
Expected: the retry/timeout settings you wrote appear in the caller's Envoy route config — ground truth that the sidecar, not the app, is enforcing them.
6. Configure outlier detection (circuit breaking)
Add outlier detection to the DestinationRule so a consistently-failing endpoint gets ejected:
# dr-outlier.yaml
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: backend
namespace: mesh-demo
spec:
host: backend
subsets:
- name: v1
labels: {version: v1}
- name: v2
labels: {version: v2}
trafficPolicy:
connectionPool:
tcp: {maxConnections: 10}
http: {http1MaxPendingRequests: 10, maxRequestsPerConnection: 10}
outlierDetection:
consecutive5xxErrors: 3
interval: 5s
baseEjectionTime: 30s
maxEjectionPercent: 100
kubectl apply -f dr-outlier.yaml
kubectl get destinationrule backend -n mesh-demo -o yaml | grep -A6 outlierDetection
Expected: the outlier-detection block is applied. With more than one backend
replica, an endpoint returning 3 consecutive 5xxs would be ejected from the
pool for 30s — traffic then avoids the sick instance automatically. (With a
single replica there's nothing to fail over to; scale backend-v1 to 3 to see
ejection in action if you want.)
7. Diagnose and fix: retries that never happen because the timeout is too tight
A common self-inflicted bug: the route timeout is shorter than the time the retries need, so the timeout fires before the retries can help. Reproduce with a 5s injected delay, 3 retries at 1s each, but a 2s overall timeout:
# vs-retry-broken.yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: backend
namespace: mesh-demo
spec:
hosts: [backend]
http:
- fault:
delay:
fixedDelay: 5s
percentage: {value: 100}
timeout: 2s
retries:
attempts: 3
perTryTimeout: 1s
retryOn: "gateway-error,reset,connect-failure"
route:
- destination: {host: backend, subset: v1}
kubectl apply -f vs-retry-broken.yaml
kubectl exec -n mesh-demo deploy/frontend -- sh -c 'time curl -s -o /dev/null -w "%{http_code}\n" http://backend'
Expected: fails at ~2s with 504. The intent was "retry the slow calls," but
the overall timeout: 2s is the hard cap — it fires before the 3×1s of retries
can complete, so the retry budget is effectively wasted. This is the
"retry budget exceeds timeout" trap from Concepts. Diagnose by comparing the
numbers: attempts(3) × perTryTimeout(1s) = 3s > timeout(2s). Fix by making
the overall timeout accommodate the retries (and, since the fault here is a
delay not an abort, note that no per-try timeout shorter than 5s will ever
get a success from a 5s-delayed backend — a delay that always exceeds
perTryTimeout can't be retried into success; the real fix for transient
failures is a retryable abort, not a permanent delay):
# Widen the overall timeout so retries can run, and test against a retryable abort instead:
kubectl apply -f vs-retry.yaml
kubectl exec -n mesh-demo deploy/frontend -- curl -s -o /dev/null -w "%{http_code}\n" http://backend
Expected: with vs-retry.yaml (retryable 503 abort, no crushing overall
timeout) the retries actually run and mostly succeed. Lesson: retries only help
when the failure is retryable and the timeout leaves room for the attempts —
verify both with fault injection rather than trusting the YAML.
8. Remove all fault injection (return to healthy)
# vs-clean.yaml
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: backend
namespace: mesh-demo
spec:
hosts: [backend]
http:
- timeout: 3s
retries:
attempts: 3
perTryTimeout: 1s
retryOn: "5xx,reset,connect-failure"
route:
- destination: {host: backend, subset: v1}
kubectl apply -f vs-clean.yaml
kubectl exec -n mesh-demo deploy/frontend -- curl -s -o /dev/null -w "%{http_code}\n" http://backend
Expected: 200, fast — a healthy service with a sensible timeout and retry
policy and no injected faults. This is the steady state you'd actually run:
resilience policies in place, verified, faults removed.
9. Preview the chaos-engineering mindset
Without applying anything, write down: which of your resilience policies you'd deliberately stress-test on a schedule (not just once), how fault injection here differs from killing a whole node or zone (track 22's scope), and why "inject failure to prove recovery" is safer as a routine practice than discovering the gap during a real outage.
Expected: you'd periodically re-inject aborts/delays to confirm timeouts and retries still hold after code changes; mesh fault injection is per-request and app-scoped and instantly reversible, whereas track 22 fails whole nodes/zones/infrastructure; rehearsing failure on your terms beats meeting it unrehearsed at 3am.
Independent challenge
No YAML given — draw on this module, module 03's canary, and module 06's metrics, previewing track 22.
Task: Give backend a production-grade resilience posture and prove each
piece with fault injection: a route timeout, a bounded retry policy on
idempotent requests only, and outlier detection on the DestinationRule. Then
run three fault-injection experiments and record the observed behaviour in each
against what you predicted: (1) inject a delay longer than the timeout and
confirm it fires at the right time; (2) inject a retryable abort at 50% and
show the retry policy drops the caller-visible error rate, watching it in the
module-06 metrics/Kiali; (3) scale backend to 3 replicas, make one replica
fail consistently, and confirm outlier detection ejects it so caller error
rate recovers. Finish by writing two sentences on how these per-request
experiments are a scaled-down rehearsal for the node/zone failure testing in
track 22.
Stuck? One hint
Timeout and retries live on the VirtualService route; connection-pool limits
and outlierDetection live on the DestinationRule. Ensure
attempts × perTryTimeout fits inside the overall timeout, and only put
retries on GET-like idempotent traffic. To make "one replica fail," give it a
different image/args that returns 503 (or inject the abort scoped to its
subset) and watch Kiali's error edge recover as the endpoint is ejected — the
mesh metrics from module 06 are how you prove recovery rather than assume it.
Common mistakes & troubleshooting
- Retry budget larger than the timeout. If
attempts × perTryTimeoutexceeds the routetimeout, the timeout fires mid-retries and you never get the retries you configured (exercise 7). Size them together. - Retrying non-idempotent operations. A retried
POST/PATCHcan double-charge, double-send, or corrupt state. Scope retries to safe (idempotent) operations only. - Retrying a permanent failure. Retries help transient failures; retrying a request against a backend that always fails (or is always too slow for the per-try timeout) just wastes attempts and adds latency.
- Retry storms defeating circuit breaking. Aggressive retries generate the exact load surge a circuit breaker exists to prevent — tune retries and connection-pool limits as a pair, not independently.
- Leaving fault injection applied. A
faultblock is real config on live traffic — forget to remove it and you're injecting failures into production. Always clean it back out (exercise 8) after an experiment. - Outlier detection with too few endpoints. Ejecting endpoints only helps
when there are healthy ones left; with one replica there's nothing to fail
over to, and a high
maxEjectionPercenton a small pool can eject enough to overwhelm the survivors.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- Where do a timeout and a retry policy live in Istio, and why is putting them there better than in each app's HTTP client?
- What is fault injection, and why is it the feature that lets you prove a resilience policy instead of hoping it works?
- You set
attempts: 3,perTryTimeout: 1s, and routetimeout: 2s. What goes wrong, and how do you fix it? - Why must you be careful applying retries to non-idempotent requests?
- What's the difference between connection-pool circuit breaking and outlier detection, and where is each configured?
- How can an aggressive retry policy defeat a circuit breaker?
- How does the fault injection in this module relate to what you'll do in track 22's chaos engineering — same idea or different scope?
Show answers
- On the
VirtualServiceroute (timeoutandretries), enforced by the sidecar. Better than per-app client code because it's uniform across every service and language, and adjustable in config without redeploying the application. - Deliberately introducing delays or error responses (via a
VirtualServicefaultblock) into a percentage of requests without touching the backend. It lets you observe whether your timeout fires, your retries mask failures, and your circuit breaker trips — turning "I think this works" into a measured result. attempts × perTryTimeout(3×1s = 3s) exceeds the overalltimeout(2s), so the timeout fires before the retries can finish and the retry budget is wasted. Fix: widen the overall timeout to accommodate the attempts (and only retry genuinely retryable/transient failures).- A retry re-sends the request; for non-idempotent operations (
POSTa payment,PATCHa counter) the re-send can execute the side effect twice — double-charging, duplicate records. Only retry idempotent operations. - Connection-pool circuit breaking caps concurrent connections/requests
(rejecting excess load fast) to protect the dependency; outlier detection
watches per-endpoint error rates and ejects unhealthy instances from the
pool. Both live on the
DestinationRule(connectionPoolandoutlierDetection). - Retries multiply the request volume to a struggling backend — the surge the circuit breaker is meant to shed — so heavy retries can create exactly the overload (a "retry storm") that circuit breaking exists to prevent. Tune them together.
- Same idea (inject failure to verify recovery), smaller scope. Mesh fault injection is per-request, app-scoped, and instantly reversible; track 22 scales the philosophy up to failing whole nodes, zones, and infrastructure. This module is the gentle on-ramp.
Next
08-multi-cluster-mesh-federation — every module so far ran one mesh on one cluster. This module extends the same mesh across two, so services in different clusters can call each other through the mesh as if they were local.