+1 (850) 665-2441

When Downstream Is Slow: Circuit Breakers, Throttling, and Backpressure in Camel

Most Camel error handling advice stops at the dead letter channel: catch the failure, retry a few times, park what will not succeed. That is the right answer for a message that is broken. It is the wrong answer for a dependency that is broken, and the difference is where a lot of production incidents come from.

A downstream service that has gone slow — not down, slow — is the worst case. Every exchange blocks on a socket read. Retries pile more load onto the thing that is already struggling. Consumer threads fill up, the broker backlog grows, and a partner's degraded API becomes your outage. Redelivery does not help here. You need patterns that stop sending.

This post covers three of them and, more importantly, where each one belongs.

First: set timeouts, everywhere

Before any resilience pattern, audit your timeouts. A circuit breaker cannot protect you if the call it wraps never returns. In a Camel estate the usual offenders are HTTP components left on defaults and JDBC or JMS calls with no query or receive timeout at all.

For camel-http and friends, set both the connection and the socket/response timeout explicitly — on the component, so a new route cannot forget:

from("jms:queue:orders")
    .to("http://partner/api/orders"
        + "?httpClientConfigurer=#tightTimeouts");

Pick the numbers from the dependency's real p99, not from optimism. A 30-second default socket timeout on a call that normally takes 80 ms means one sick dependency can hold thirty seconds of thread per message. With ten consumer threads, you have built a queue-clogging machine.

Write the chosen timeouts into the route documentation. "What is the timeout on this call?" is a question we ask in every architecture review, and roughly half the time nobody knows.

Circuit breaker: stop calling the thing that is failing

Camel's circuitBreaker EIP delegates to Resilience4j (the Hystrix implementation is long gone; if you are still carrying camel-hystrix, that is an upgrade item). It wraps a route segment, tracks failures, and when the failure rate crosses a threshold it opens — subsequent exchanges fail immediately, without touching the dependency, until a probe window suggests recovery.

from("jms:queue:orders")
  .circuitBreaker()
    .resilience4jConfiguration()
      .failureRateThreshold(50)
      .slidingWindowSize(20)
      .waitDurationInOpenState(10000)
    .end()
    .to("http://partner/api/orders")
  .onFallback()
    .to("direct:parkForLaterSubmission")
  .end();

Three things to get right:

The fallback must be honest. onFallback() is not a place to shrug. If the operation matters, the fallback is "persist the intent and replay it later" — an outbox row, a retry queue, a parked file with a replay job. Returning an empty body and a 200 to your caller because the dependency was down is how silent data loss ships. Use onFallbackViaNetwork() only if the fallback target is genuinely independent of the failing one.

Size the window for your traffic. A sliding window of 100 calls on a route that handles four messages an hour means the breaker effectively never opens. On a 2,000/second route, a 10-call window opens on noise. Tune per route; there is no global default worth having.

One breaker per dependency, not per route. If six routes call the same partner API, six independent breakers each learn the outage separately and each keep hammering. Share configuration by name (resilience4jConfiguration referencing a common config, or a bulkhead-per-dependency design) so the estate reacts to a sick dependency as one system.

A breaker is also a great observability surface. Resilience4j publishes state to Micrometer; put breaker state on the dashboard next to route throughput. "Breaker open" is the single clearest signal that the problem is not yours.

Throttling: protect the dependency from you

A circuit breaker reacts after failures. Throttling prevents them. If a partner tells you their API accepts 50 requests per second, encode that:

from("direct:submit")
    .throttle(50).timePeriodMillis(1000)
    .to("http://partner/api/orders");

The throttle EIP delays exchanges to hold the rate. That delay is the point — and also the catch. Throttling a route whose consumer holds a transaction or a broker acknowledgement means you are now holding those open while you wait. On a JMS consumer with a transacted session, an aggressive throttle plus a long backlog is a recipe for redelivery storms and expired transactions.

So: throttle on the producer side of an asynchronous hop, not in the middle of a transacted consumer. A common shape is to let the consumer land work on an internal queue (or a SEDA endpoint with a bounded queue) quickly, and throttle the route that drains it. The backlog then lives somewhere designed to hold backlog, instead of in a broker's redelivery counter.

Related knobs worth knowing: camel-resilience4j bulkheads bound concurrent calls rather than rate, which is often the better fit for a dependency that degrades under concurrency; and Camel's own route-level maxInflightExchanges gives you a blunt but effective ceiling.

Backpressure: the pattern people skip

Circuit breakers and throttles both assume you can afford to slow down or shed. Backpressure is the design property that makes slowing down safe: when a downstream stage is slow, the upstream stage stops pulling work.

Camel gives you this naturally when you let it. A polling consumer — file, FTP, JDBC, camel-aws-s3 — only fetches the next batch when the previous one has been processed. A JMS consumer with a fixed concurrentConsumers count and prefetch of 1 pulls exactly as fast as it drains. That is backpressure, and it is free.

You lose it the moment you insert an unbounded buffer. Two common ways:

  • seda: with default (unbounded) queue size. The route accepts everything instantly, the queue grows, and heap becomes your flow-control mechanism. Set size= deliberately and choose a blockWhenFull=true policy so producers actually feel the pressure instead of getting an exception or an OOM.
  • wireTap and fire-and-forget async hops. Useful, but they decouple the producer from the consumer's real capacity. Fine for audit logging; dangerous for anything that must keep up.

Kafka consumers deserve their own note. max.poll.records and the consumer's processing time interact with max.poll.interval.ms: a route that slows down — because you throttled it, or because the breaker's fallback got expensive — can blow past the poll interval and get kicked out of the group, which looks like a rebalance storm and gets misdiagnosed as a broker problem. If you add throttling to a Kafka-fed route, revisit those two settings in the same change.

Putting them together

A route calling a flaky external API usually wants all three, in layers:

  1. Timeouts on the call, so failure is fast and bounded.
  2. Bounded concurrency (consumer threads, bulkhead) so a slow dependency cannot consume the whole pool.
  3. A circuit breaker with a durable fallback, so a sustained outage stops generating load and starts generating a replayable backlog.
  4. A throttle on the drain side, sized to what the dependency actually tolerates, so recovery does not immediately re-break it.

That last point is the one teams miss. When the breaker closes and the parked backlog is released at full speed, you re-DoS the dependency that just came back. Throttle the replay path harder than the live path.

The trade-off, stated plainly

Every pattern here converts a failure into a delay, and delay is not free either. A breaker that opens means messages are not being processed; a throttle means the backlog grows. Both are better than an outage that propagates, but both need someone watching: breaker state, backlog depth, and parked-message counts belong on a dashboard with alerts, not in a log file nobody reads.

And sometimes the simpler tool wins. If a dependency is reliably slow rather than intermittently broken, a circuit breaker is theatre — what you need is an asynchronous design with a queue and a realistic SLA, not a faster retry. Resilience patterns are for systems that mostly work. For systems that mostly do not, fix the architecture.

If you are carrying routes where nobody is sure what the timeouts are or what happens when the partner API goes dark, that is exactly the ground our integration architecture review covers.