Fallback Stands Outside Retry and Circuit Breaker
Applies to
@connectum/interceptors1.3.0.
Does this apply to you?
You need to act only if you pass fallback to createDefaultInterceptors() together with retry or circuitBreaker. Without fallback, the chain is unchanged. If you compose the interceptors yourself, nothing changes until you reorder them.
What changed
Until 1.3.0 the fallback stood inside circuitBreaker and retry. It answered the first failure of the handler with a value, so neither interceptor ever saw an error:
With fallback and ... | Before | From 1.3.0 |
|---|---|---|
retry: { maxRetries: 3 }, handler always fails | Handler called once, fallback value served | Handler called four times, then the fallback value is served |
retry, handler fails once, then succeeds | Fallback value served | The response of the successful retry; the fallback is not asked |
circuitBreaker: { threshold: 2 }, handler always fails | Breaker never opens; every call reaches the handler | Breaker opens after two failures; later calls do not reach the handler and are answered with the fallback value |
The handler of the fallback now receives the error that ended the call: the last failure after the retries, or the Unavailable "Circuit breaker is open" error while the breaker is open. A code that retry does not repeat (Internal, NotFound, ...) is not retried, and the handler gets that first error. With a fallback, validation stands directly outside it, so an invalid request is still rejected with InvalidArgument. The full chain is in Fallback position.
Other consequences of the new order, all of them for chains that enable a fallback:
- Cancellation reaches the handler. After the caller cancels or the deadline passes, retry raises
Canceled(orDeadlineExceeded). That error now passes through the fallback, so thehandleris called with it. The client still receives the cancellation or the deadline from the layer that raised it, but side effects of thehandler(metrics, logs, a cache write) now also run for such calls. Filter on the error code in thehandlerif they must not. - An error of the handler itself is not repeated. If the
handlerthrows, the error goes straight to the error handler: retry does not repeat it and the breaker does not count it. Before, a throwinghandlercould open the breaker. - Validation beside an open breaker. With the breaker open, an invalid request now gets
InvalidArgument(validation stands outside). Without a fallback it still getsUnavailable"Circuit breaker is open", because validation stays behind the breaker there. AfailurePredicatethat countsInvalidArgumenttherefore counts invalid requests only in a chain without a fallback. - Streams. With
skipStreaming: falseon bothretryandfallback, the opening of a stream that fails withUnavailableis attemptedmaxRetries + 1times, and then the fallback message is the only message of the stream. With the defaultskipStreaming: truestreams bypass both, as before.
Required changes
- Check the load on the handler. A failing dependency now receives the retries you configured, before the fallback is used. Lower
maxRetriesif that is too much. - Check what reads the fallback. If a dashboard counted fallback answers as a measure of failed first attempts, it now counts exhausted retries and breaker refusals; if the
handlerhas side effects, see the cancellation point above. - Composing the chain yourself: list
createFallbackInterceptorbeforecreateCircuitBreakerInterceptorandcreateRetryInterceptor, and the validation interceptor before the fallback.
Verify
Call a method whose handler always fails and count the handler calls: with retry: { maxRetries: 3 } and a fallback you must see four, and with circuitBreaker: { threshold: 2 } you must see two over four sequential calls while every caller receives the fallback value.