7 minutes
Camel sorts your URI parameters, except when it doesn’t
Update (September 2026): After this post went out, Claus Ibsen (Apache Camel maintainer) opened CAMEL-24524 for exactly this branch and fixed it two days later in PR #25810. It ships in Camel 4.23.0. Details at the end of the post. Everything below still describes the behaviour on 4.8.5 and every release before 4.23.0.
We recently shipped a fix at work for a Kafka producer leak: a URI was being built with the record key baked into it, so every distinct key produced a distinct Camel endpoint, and every endpoint got its own KafkaProducer, its own network thread, and its own 10-thread worker pool. One string, hundreds of producers.
Fixing it was easy. Understanding why Camel behaved that way sent me into camel-core, and on the way I tripped over something I haven’t seen written down anywhere.
Short version: Camel normalises endpoint URIs by sorting the query parameters, so parameter order shouldn’t matter. It usually doesn’t. But if any parameter value contains a character that needs URL-encoding, order suddenly does matter, and you silently get two endpoints where you meant one.
Every Kafka broker string contains such a character. It’s the colon in host:port.
Endpoint identity is the URI string
Some background, if you haven’t been inside Camel.
When you call camelContext.getEndpoint(uri), Camel doesn’t build a fresh endpoint each time. It normalises the URI, looks it up in an LRU registry keyed by that normalised string, and returns the cached instance if there is one. The endpoint owns the producer, and the producer owns the connections and threads.
So the normalisation step is load-bearing. It’s what makes kafka:t?a=1&b=2 and kafka:t?b=2&a=1 mean the same thing. That’s the whole point of it. The Javadoc says as much:
normalize uri so we can do endpoint hits with minor mistakes and parameters is not in the same order
The experiment
Two pairs of URIs. Each pair is the same URI written with the parameters in a different order.
// (A) no unsafe characters in any value
Endpoint a1 = ctx.getEndpoint("kafka:t1?retries=3&clientId=abc");
Endpoint a2 = ctx.getEndpoint("kafka:t1?clientId=abc&retries=3");
// (B) a ':' in one value
Endpoint b1 = ctx.getEndpoint("kafka:t2?retries=3&brokers=localhost:19092");
Endpoint b2 = ctx.getEndpoint("kafka:t2?brokers=localhost:19092&retries=3");
Output, on Camel 4.8.5:
A same? true
kafka://t1?clientId=abc&retries=3
kafka://t1?clientId=abc&retries=3
B same? false
kafka://t2?brokers=localhost%3A19092&retries=3
kafka://t2?brokers=localhost:19092&retries=3
registry: 3
Case A behaves exactly as advertised. Case B does not. Look closely at how it fails.
The sorting worked. Both B strings have brokers before retries. The keys are in identical order. The strings still differ, by exactly one character: %3A versus :.
Three endpoints in a registry where I asked for two.
The branch
Here’s URISupport.buildReorderingParameters, the method that does the work (core/camel-util, Camel 4.8.5):
private static String buildReorderingParameters(String scheme, String path, String query) {
Map<String, Object> parameters = null;
if (query.indexOf('&') != -1) {
parameters = URISupport.parseQuery(query, false, false);
}
if (parameters != null && parameters.size() != 1) {
final Set<String> entries = parameters.keySet();
// reorder parameters a..z
// optimize and only build new query if the keys was resorted
boolean sort = false;
String prev = null;
for (String key : entries) {
if (prev != null && key.compareTo(prev) < 0) {
sort = true;
break;
}
prev = key;
}
if (sort) {
final String[] array = entries.toArray(new String[0]);
Arrays.sort(array);
query = URISupport.createQueryString(array, parameters, true);
}
}
return buildUri(scheme, path, query);
}
The comment tells you the intent: only build a new query string if the keys were actually resorted. Sensible optimisation. Rebuilding a string you don’t need to rebuild is waste.
The catch is that rebuilding the query string is also the only thing that applies encoding. That third argument to createQueryString(array, parameters, true) is encode. When sort stays false, the method never runs, and the original query substring is concatenated into the final URI verbatim: never parsed, never encoded.
So sorting and encoding are coupled to the same flag:
| input order | sort | query rebuilt? | encoded? |
|---|---|---|---|
| already alphabetical | false | no | no |
| needs reordering | true | yes | yes |
Two inputs that describe the same endpoint take two different paths and come out as two different strings. Both are “normalised”. Neither is wrong on its own terms. They just aren’t equal, and equality is what the registry uses.
Note the two other early exits, which have the same effect: a query with no & at all (single parameter), and parameters.size() == 1. Both skip the block entirely and stay unencoded.
Why this is worse than it looks
The failure is completely silent. You don’t get an exception, a warning, or a log line. You get an extra endpoint, and in the Kafka case that means:
- an extra
KafkaProducer - an extra
kafka-producer-network-thread - an extra worker thread pool, which in Camel’s Kafka component grows to 10 threads under load
- an extra set of TCP connections, and if you’re on
SASL_SSL, an extra TLS and SASL handshake plus roughly 50 KB of on-heap buffers per connection
All because two call sites in two different files wrote the same parameters in a different order.
And the trigger isn’t exotic. : is unsafe in a URI query. Every Kafka bootstrap server is host:port. Any URI you hand-assemble by string concatenation is a candidate.
Why our production code was fine
This is the part that made me stop worrying and start appreciating the endpoint DSL.
We never hand-write Kafka URIs. They’re built through Camel’s fluent builder:
kafka(topicName).brokers(bootStrapServer)
.enableIdempotence(true)
.retries(KAFKA_MAX_RETRIES)
.requestRequiredAcks(KAFKA_ACKS_ALL)
And AbstractEndpointBuilder.computeUri does this:
// sort parameters so it can be regarded as normalized
Map<String, Object> params = new TreeMap<>();
A TreeMap. The DSL emits parameters in alphabetical order by construction, so Camel’s normaliser always sees an already-sorted query, always takes the pass-through branch, and always produces a byte-identical string. Every call site agrees, deterministically.
That’s why a piece of code in our codebase that rebuilds its Kafka URI on every single invocation is safe rather than catastrophic. It isn’t luck. It’s that TreeMap.
It also means the safety is a property of the builder, not of Camel’s normaliser. Replace one kafka(topic).brokers(...) call with a “simpler” string template and you reintroduce the bug in a form that is genuinely hard to see in review: the two URIs look identical to a human, because as descriptions of an endpoint, they are.
Update: fixed upstream in Camel 4.23.0
A few weeks after publishing this, Claus Ibsen (one of Camel’s maintainers) read the post and filed CAMEL-24524 against camel-core:
URISupport.normalizeUrifast path skips value encoding when parameters are already sorted, causing duplicate endpoints
The Jira traces the behaviour back to CAMEL-14648 (2020), the change that introduced the skip-if-already-sorted optimisation. The fast parser treats : as safe; URLEncoder.encode does not. Coupling the rebuild to the sort flag made which of the two applied depend on the order the caller happened to write the parameters in.
It was fixed in PR #25810, merged 28 August 2026, released in Camel 4.23.0.
The fix decouples the two concerns: buildReorderingParameters now always rebuilds the query string, so encoding no longer rides on the sort flag. The encoding it applies is UnsafeUriCharactersEncoder (RFC 3986-aware) rather than the more aggressive URLEncoder, with & and = additionally escaped so Camel’s own key=value&key=value syntax survives. That keeps :, / and , literal in the normalised output, which is what the rest of the framework already assumed.
So on 4.23.0 and later, both of my case-B URIs normalise to the same string and you get one endpoint. On anything earlier, the takeaways below still apply, and they’re worth keeping regardless, because sorting your parameters at the call site costs nothing and doesn’t depend on which Camel version you’re pinned to.
Takeaways
- Endpoint identity in Camel is the normalised URI string, not the endpoint’s meaning. Anything that changes the string changes the identity, and identity is what owns your connections and threads.
- Never hand-assemble Camel endpoint URIs. Use the endpoint DSL. The
TreeMapis doing more for you than readability. - Normalisation is not idempotent across input orderings when values need encoding. If you must build a URI by hand, sort your parameters alphabetically yourself, or you’re relying on a branch that was written as a performance optimisation. (Fixed in 4.23.0, but your call sites outlive your version pins.)
- When you’re debugging “why do I have N of these”, print
endpoint.getEndpointUri()for every entry in the registry and diff them character by character. The difference may be one character wide.
All line references are against the camel-4.8.5 tag in the Apache Camel repository. The relevant code is URISupport.buildReorderingParameters in core/camel-util, and AbstractEndpointBuilder.computeUri in dsl/camel-endpointdsl.