This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 95fe714f404f CAMEL-24551: Expose structured AI error metadata on
producer failures
95fe714f404f is described below
commit 95fe714f404f3b097fa071c01212458f5080ae1e
Author: Omar Atie <[email protected]>
AuthorDate: Sun Aug 30 23:37:44 2026 -0700
CAMEL-24551: Expose structured AI error metadata on producer failures
When an AI producer call fails, Camel now sets structured error metadata
on the Exchange before the exception propagates, so routes can react in
onException without introspecting SDK-specific exception types.
Adds two exchange properties: CamelAiErrorCategory (RATE_LIMIT,
SERVER_ERROR, VALIDATION, AUTH, UNKNOWN) and CamelAiRetryAfterMillis
(OpenAI only, parsed from Retry-After / Retry-After-Ms headers).
Classification is handled by a new shared GenAiErrorSupport in
camel-ai-observability-api, using class-name/hierarchy matching and
reflection so it works independent of GenAI observability being enabled
and without a compile dependency on any specific AI SDK. Wired into the
openai, langchain4j-chat, langchain4j-agent, langchain4j-embeddings, and
spring-ai-chat producers, with matching documentation and test coverage
for each.
Closes #25893
Co-authored-by: Cursor Agent <[email protected]>
---
.../catalog/docs/langchain4j-agent-component.adoc | 31 ++
.../catalog/docs/langchain4j-chat-component.adoc | 29 ++
.../docs/langchain4j-embeddings-component.adoc | 15 +
.../camel/catalog/docs/openai-component.adoc | 32 ++
.../catalog/docs/spring-ai-chat-component.adoc | 31 ++
.../camel-ai/camel-ai-observability-api/pom.xml | 13 +-
.../ai/observability/GenAiErrorCategory.java | 30 ++
.../ai/observability/GenAiErrorProperties.java | 38 +++
.../ai/observability/GenAiErrorSupport.java | 348 +++++++++++++++++++++
.../ai/observability/GenAiErrorSupportTest.java | 106 +++++++
.../ai/retry/NonTransientAiException.java | 26 ++
.../ai/retry/TransientAiException.java | 30 ++
.../src/main/docs/langchain4j-agent-component.adoc | 31 ++
.../agent/LangChain4jAgentProducer.java | 2 +
.../agent/LangChain4jAgentErrorMetadataTest.java | 85 +++++
.../src/main/docs/langchain4j-chat-component.adoc | 29 ++
.../langchain4j/chat/LangChain4jChatProducer.java | 3 +
.../chat/LangChain4jChatErrorMetadataTest.java | 96 ++++++
.../docs/langchain4j-embeddings-component.adoc | 15 +
.../embeddings/LangChain4jEmbeddingsProducer.java | 2 +
.../LangChain4jEmbeddingsErrorMetadataTest.java | 103 ++++++
.../src/main/docs/openai-component.adoc | 32 ++
.../camel/component/openai/OpenAIProducer.java | 40 ++-
.../component/openai/OpenAIErrorMetadataTest.java | 75 +++++
.../src/main/docs/ai-llm-integration-guide.adoc | 29 ++
.../src/main/docs/spring-ai-chat-component.adoc | 31 ++
.../springai/chat/SpringAiChatProducer.java | 3 +
.../chat/SpringAiChatErrorMetadataTest.java | 92 ++++++
28 files changed, 1387 insertions(+), 10 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc
index 542581d694ff..a331982d11df 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-agent-component.adoc
@@ -1178,6 +1178,37 @@ You can also define the `ResponseFormat` at the
`ChatModel` level. See the https
* The same schema file can be shared across `camel-openai` and
`camel-langchain4j-agent` components
====
+[[structured_error_exchange_properties]]
+=== Structured error exchange properties
+
+When a LangChain4j agent call fails, Camel sets structured metadata on the
exchange **before** the model exception propagates. This works even when GenAI
observability is disabled.
+
+[cols="2,3"]
+|===
+| Exchange property | Meaning
+
+| `CamelAiErrorCategory` | Coarse category derived from the LangChain4j
exception: `RATE_LIMIT`, `SERVER_ERROR`, `VALIDATION`, `AUTH`, or `UNKNOWN`
+| `CamelAiRetryAfterMillis` | Not populated for LangChain4j providers
(OpenAI-only today)
+|===
+
+Category-based handling complements matching on LangChain4j exception types in
xref:langchain4j-chat-component.adoc#_error_handling[LangChain4j Chat error
handling]:
+
+[source, java]
+----
+onException(Exception.class)
+ .process(exchange -> {
+ String category = exchange.getProperty("CamelAiErrorCategory",
String.class);
+ if ("RATE_LIMIT".equals(category)) {
+ exchange.getIn().setHeader("RateLimited", true);
+ }
+ })
+ .maximumRedeliveries(3)
+ .handled(true)
+ .to("direct:rate-limited");
+----
+
+See
xref:ai-llm-integration-guide.adoc#_structured_error_exchange_properties[AI LLM
integration guide] for a cross-component overview.
+
== Sub-Pages
For more details on specific features, see:
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-chat-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-chat-component.adoc
index cbe442b39008..18e4ca3dc39e 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-chat-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-chat-component.adoc
@@ -367,6 +367,35 @@ from("direct:chat")
.to("langchain4j-chat:my-chat");
----
+=== Structured error exchange properties
+
+When a LangChain4j chat call fails, Camel sets structured metadata on the
exchange **before** the model exception propagates. This works even when GenAI
observability is disabled.
+
+[cols="2,3"]
+|===
+| Exchange property | Meaning
+
+| `CamelAiErrorCategory` | Coarse category derived from the LangChain4j
exception: `RATE_LIMIT`, `SERVER_ERROR`, `VALIDATION`, `AUTH`, or `UNKNOWN`
+| `CamelAiRetryAfterMillis` | Not populated for LangChain4j providers
(OpenAI-only today)
+|===
+
+Category-based handling complements matching on `RetriableException` /
`NonRetriableException` above:
+
+[source, java]
+----
+onException(Exception.class)
+ .process(exchange -> {
+ String category = exchange.getProperty("CamelAiErrorCategory",
String.class);
+ if ("VALIDATION".equals(category)) {
+ exchange.getIn().setHeader("Rejected", true);
+ }
+ })
+ .handled(true)
+ .to("direct:rejected");
+----
+
+The same properties are set by
xref:langchain4j-agent-component.adoc[LangChain4j Agent] and
xref:langchain4j-embeddings-component.adoc[LangChain4j Embeddings] on their
failure paths. See
xref:ai-llm-integration-guide.adoc#_structured_error_exchange_properties[AI LLM
integration guide] for a cross-component overview.
+
IMPORTANT: The mapping is performed by the LangChain4j model implementation,
not by Camel. `OpenAiChatModel` and `OllamaChatModel` route their calls through
LangChain4j's exception mapper, but a model that does not will propagate the
raw exception of its own HTTP client instead. Verify what your provider throws
before relying on the type.
=== Model retries vs Camel redelivery
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-embeddings-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-embeddings-component.adoc
index a145699c1524..387d9e382f24 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-embeddings-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-embeddings-component.adoc
@@ -394,3 +394,18 @@ YAML::
returnTextContent: true
----
====
+
+[[structured_error_exchange_properties]]
+=== Structured error exchange properties
+
+When a LangChain4j embeddings call fails, Camel sets structured metadata on
the exchange **before** the model exception propagates. This works even when
GenAI observability is disabled.
+
+[cols="2,3"]
+|===
+| Exchange property | Meaning
+
+| `CamelAiErrorCategory` | Coarse category derived from the LangChain4j
exception: `RATE_LIMIT`, `SERVER_ERROR`, `VALIDATION`, `AUTH`, or `UNKNOWN`
+| `CamelAiRetryAfterMillis` | Not populated for LangChain4j providers
(OpenAI-only today)
+|===
+
+Use categories when a route should branch on failure type without matching
every LangChain4j exception class. See
xref:langchain4j-chat-component.adoc#_structured_error_exchange_properties[LangChain4j
Chat structured error properties] and
xref:ai-llm-integration-guide.adoc#_structured_error_exchange_properties[AI LLM
integration guide] for related detail.
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-component.adoc
index e969ff2d3f46..90a32cae5157 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-component.adoc
@@ -1366,6 +1366,38 @@ onException(OpenAIServiceException.class)
`headers()` on the same exception exposes the response headers, including
`Retry-After` on a 429.
+=== Structured error exchange properties
+
+When an OpenAI producer call fails, Camel sets structured metadata on the
exchange **before** the SDK exception propagates. This works even when GenAI
observability is disabled.
+
+[cols="2,3"]
+|===
+| Exchange property | Meaning
+
+| `CamelAiErrorCategory` | Coarse category: `RATE_LIMIT`, `SERVER_ERROR`,
`VALIDATION`, `AUTH`, or `UNKNOWN`
+| `CamelAiRetryAfterMillis` | Suggested retry delay in milliseconds when the
OpenAI SDK exposes `Retry-After` or `Retry-After-Ms` on a 429; absent for other
providers and error types
+|===
+
+Use these when a route should branch on category without matching every SDK
type, or when you want a single `onException` policy across OpenAI,
LangChain4j, and Spring AI producers:
+
+[source,java]
+----
+onException(Exception.class)
+ .process(exchange -> {
+ String category = exchange.getProperty("CamelAiErrorCategory",
String.class);
+ Long retryAfter = exchange.getProperty("CamelAiRetryAfterMillis",
Long.class);
+ if ("RATE_LIMIT".equals(category)) {
+ long delay = retryAfter != null ? retryAfter : 2000L;
+ exchange.getIn().setHeader("RetryDelay", delay);
+ }
+ })
+ .maximumRedeliveries(3)
+ .handled(true)
+ .to("direct:rate-limited");
+----
+
+The SDK exception is still available as `Exchange.EXCEPTION_CAUGHT`, so you
can combine coarse categories with the fine-grained types in
<<_targeting_sdk_exceptions_with_onexception>>.
+
=== SDK Retry vs Camel Redelivery
The SDK client retries on its own before the exception ever reaches Camel. The
`maxRetries` option controls this and defaults to `2`, so one exchange already
issues up to three HTTP requests. The SDK retries 408, 409, 429 and 5xx
responses as well as connection failures, backing off exponentially and
honoring the `Retry-After` and `Retry-After-Ms` response headers.
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-chat-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-chat-component.adoc
index d2fd642a768a..c8d8c0a84040 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-chat-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-chat-component.adoc
@@ -1407,6 +1407,37 @@ The component automatically adds Spring AI's
`SimpleLoggerAdvisor` to log reques
logging.level.org.springframework.ai.chat.client.advisor=DEBUG
----
+[[structured_error_exchange_properties]]
+=== Structured error exchange properties
+
+When a Spring AI chat call fails, Camel sets structured metadata on the
exchange **before** the Spring AI exception propagates. This works even when
GenAI observability is disabled.
+
+[cols="2,3"]
+|===
+| Exchange property | Meaning
+
+| `CamelAiErrorCategory` | Coarse category derived from Spring AI retry
exceptions: `SERVER_ERROR` for `TransientAiException`, `VALIDATION` for
`NonTransientAiException`, or `UNKNOWN` when no mapping applies
+| `CamelAiRetryAfterMillis` | Not populated for Spring AI providers
(OpenAI-only today)
+|===
+
+Category-based handling complements matching on Spring AI exception types
directly:
+
+[source,java]
+----
+onException(Exception.class)
+ .process(exchange -> {
+ String category = exchange.getProperty("CamelAiErrorCategory",
String.class);
+ if ("SERVER_ERROR".equals(category)) {
+ exchange.getIn().setHeader("Retryable", true);
+ }
+ })
+ .maximumRedeliveries(3)
+ .handled(true)
+ .to("direct:retry");
+----
+
+See
xref:ai-llm-integration-guide.adoc#_structured_error_exchange_properties[AI LLM
integration guide] for a cross-component overview.
+
== See Also
* https://docs.spring.io/spring-ai/reference/[Spring AI Documentation]
diff --git a/components/camel-ai/camel-ai-observability-api/pom.xml
b/components/camel-ai/camel-ai-observability-api/pom.xml
index f1723965c37d..b61b987baa86 100644
--- a/components/camel-ai/camel-ai-observability-api/pom.xml
+++ b/components/camel-ai/camel-ai-observability-api/pom.xml
@@ -47,7 +47,18 @@
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-core</artifactId>
<version>${langchain4j-version}</version>
- <optional>true</optional>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-test-junit6</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
</dependency>
</dependencies>
diff --git
a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorCategory.java
b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorCategory.java
new file mode 100644
index 000000000000..db9660ab9362
--- /dev/null
+++
b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorCategory.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ai.observability;
+
+/**
+ * High-level classification of AI provider failures for route error handling.
+ *
+ * @since 4.23
+ */
+public enum GenAiErrorCategory {
+ RATE_LIMIT,
+ SERVER_ERROR,
+ VALIDATION,
+ AUTH,
+ UNKNOWN
+}
diff --git
a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorProperties.java
b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorProperties.java
new file mode 100644
index 000000000000..26f15df01deb
--- /dev/null
+++
b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorProperties.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ai.observability;
+
+/**
+ * Exchange property names for structured AI error metadata.
+ *
+ * @since 4.23
+ */
+public final class GenAiErrorProperties {
+
+ /**
+ * Error category ({@link GenAiErrorCategory#name()}) derived from the
underlying SDK exception.
+ */
+ public static final String ERROR_CATEGORY = "CamelAiErrorCategory";
+
+ /**
+ * Suggested retry delay in milliseconds when the provider exposes {@code
Retry-After} (OpenAI only).
+ */
+ public static final String RETRY_AFTER_MILLIS = "CamelAiRetryAfterMillis";
+
+ private GenAiErrorProperties() {
+ }
+}
diff --git
a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorSupport.java
b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorSupport.java
new file mode 100644
index 000000000000..dc7ce3ccc68d
--- /dev/null
+++
b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiErrorSupport.java
@@ -0,0 +1,348 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ai.observability;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.camel.Exchange;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Populates exchange properties with structured AI error metadata before an
exception propagates.
+ * <p/>
+ * This runs independently of GenAI observability spans so routes can react in
{@code onException} even when
+ * {@code camel-ai-observability} is absent or disabled.
+ *
+ * @since 4.23
+ */
+public final class GenAiErrorSupport {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(GenAiErrorSupport.class);
+
+ private static final Map<String, GenAiErrorCategory>
LANGCHAIN4J_EXCEPTION_CATEGORIES = Map.ofEntries(
+ Map.entry("dev.langchain4j.exception.RateLimitException",
GenAiErrorCategory.RATE_LIMIT),
+ Map.entry("dev.langchain4j.exception.InternalServerException",
GenAiErrorCategory.SERVER_ERROR),
+ Map.entry("dev.langchain4j.exception.TimeoutException",
GenAiErrorCategory.SERVER_ERROR),
+
Map.entry("dev.langchain4j.exception.UnresolvedModelServerException",
GenAiErrorCategory.SERVER_ERROR),
+ Map.entry("dev.langchain4j.exception.RetriableException",
GenAiErrorCategory.SERVER_ERROR),
+ Map.entry("dev.langchain4j.exception.AuthenticationException",
GenAiErrorCategory.AUTH),
+ Map.entry("dev.langchain4j.exception.InvalidRequestException",
GenAiErrorCategory.VALIDATION),
+ Map.entry("dev.langchain4j.exception.ContentFilteredException",
GenAiErrorCategory.VALIDATION),
+ Map.entry("dev.langchain4j.exception.ModelNotFoundException",
GenAiErrorCategory.VALIDATION),
+ Map.entry("dev.langchain4j.exception.ToolArgumentsException",
GenAiErrorCategory.VALIDATION));
+
+ private static final Map<String, GenAiErrorCategory>
OPENAI_EXCEPTION_CATEGORIES = Map.ofEntries(
+ Map.entry("com.openai.errors.RateLimitException",
GenAiErrorCategory.RATE_LIMIT),
+ Map.entry("com.openai.errors.InternalServerException",
GenAiErrorCategory.SERVER_ERROR),
+ Map.entry("com.openai.errors.BadRequestException",
GenAiErrorCategory.VALIDATION),
+ Map.entry("com.openai.errors.UnprocessableEntityException",
GenAiErrorCategory.VALIDATION),
+ Map.entry("com.openai.errors.NotFoundException",
GenAiErrorCategory.VALIDATION),
+ Map.entry("com.openai.errors.UnauthorizedException",
GenAiErrorCategory.AUTH),
+ Map.entry("com.openai.errors.PermissionDeniedException",
GenAiErrorCategory.AUTH),
+ Map.entry("com.openai.errors.OpenAIRetryableException",
GenAiErrorCategory.SERVER_ERROR),
+ Map.entry("com.openai.errors.OpenAIIoException",
GenAiErrorCategory.SERVER_ERROR),
+ Map.entry("com.openai.errors.OpenAIInvalidDataException",
GenAiErrorCategory.VALIDATION));
+
+ private static final Map<String, GenAiErrorCategory>
SPRING_AI_EXCEPTION_CATEGORIES = Map.of(
+ "org.springframework.ai.retry.TransientAiException",
GenAiErrorCategory.SERVER_ERROR,
+ "org.springframework.ai.retry.NonTransientAiException",
GenAiErrorCategory.VALIDATION);
+
+ private static final Set<String> RETRY_AFTER_MS_HEADER_NAMES =
Set.of("Retry-After-Ms", "retry-after-ms");
+ private static final Set<String> RETRY_AFTER_HEADER_NAMES =
Set.of("Retry-After", "retry-after");
+
+ private GenAiErrorSupport() {
+ }
+
+ /**
+ * Sets {@link GenAiErrorProperties#ERROR_CATEGORY} and, when available,
+ * {@link GenAiErrorProperties#RETRY_AFTER_MILLIS} on the exchange.
+ */
+ public static void apply(Exchange exchange, Throwable error) {
+ if (exchange == null || error == null) {
+ return;
+ }
+ try {
+ GenAiErrorCategory category = classify(error);
+ exchange.setProperty(GenAiErrorProperties.ERROR_CATEGORY,
category.name());
+ Long retryAfterMillis = extractRetryAfterMillis(error);
+ if (retryAfterMillis != null) {
+ exchange.setProperty(GenAiErrorProperties.RETRY_AFTER_MILLIS,
retryAfterMillis);
+ }
+ } catch (Exception e) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Unable to populate GenAI error metadata on
exchange", e);
+ }
+ }
+ }
+
+ /**
+ * Classifies an AI provider failure, walking the exception cause chain.
+ * <p/>
+ * Classification runs in two passes over the cause chain: first with
provider-specific mappings (LangChain4j,
+ * OpenAI, and HTTP status codes), then with generic Spring AI retry
wrappers. That ordering ensures a
+ * {@code TransientAiException} wrapping a {@code RateLimitException} is
classified as {@code RATE_LIMIT} from the
+ * specific cause rather than {@code SERVER_ERROR} from the coarse Spring
AI wrapper.
+ */
+ public static GenAiErrorCategory classify(Throwable error) {
+ GenAiErrorCategory category = classifyChain(error, false);
+ if (category != GenAiErrorCategory.UNKNOWN) {
+ return category;
+ }
+ return classifyChain(error, true);
+ }
+
+ /**
+ * Extracts retry delay in milliseconds when the provider exposes {@code
Retry-After}. Currently populated for
+ * OpenAI {@code OpenAIServiceException} responses only.
+ */
+ public static Long extractRetryAfterMillis(Throwable error) {
+ Throwable current = error;
+ while (current != null) {
+ Long retryAfter = extractOpenAiRetryAfterMillis(current);
+ if (retryAfter != null) {
+ return retryAfter;
+ }
+ current = current.getCause();
+ }
+ return null;
+ }
+
+ private static GenAiErrorCategory classifyChain(Throwable error, boolean
allowSpringAiGeneric) {
+ Throwable current = error;
+ while (current != null) {
+ GenAiErrorCategory category = classifySingle(current,
allowSpringAiGeneric);
+ if (category != GenAiErrorCategory.UNKNOWN) {
+ return category;
+ }
+ current = current.getCause();
+ }
+ return GenAiErrorCategory.UNKNOWN;
+ }
+
+ private static GenAiErrorCategory classifySingle(Throwable error, boolean
allowSpringAiGeneric) {
+ GenAiErrorCategory langChain4jCategory = classifyByHierarchy(error,
LANGCHAIN4J_EXCEPTION_CATEGORIES);
+ if (langChain4jCategory != GenAiErrorCategory.UNKNOWN) {
+ return langChain4jCategory;
+ }
+ if
("dev.langchain4j.exception.HttpException".equals(error.getClass().getName())) {
+ return fromHttpStatus(invokeIntMethod(error, "statusCode"));
+ }
+
+ GenAiErrorCategory openAiCategory = classifyByHierarchy(error,
OPENAI_EXCEPTION_CATEGORIES);
+ if (openAiCategory != GenAiErrorCategory.UNKNOWN) {
+ return openAiCategory;
+ }
+ if (error.getClass().getName().startsWith("com.openai.errors.")) {
+ return fromHttpStatus(invokeIntMethod(error, "statusCode"));
+ }
+
+ if (allowSpringAiGeneric) {
+ GenAiErrorCategory springAiCategory = classifyByHierarchy(error,
SPRING_AI_EXCEPTION_CATEGORIES);
+ if (springAiCategory != GenAiErrorCategory.UNKNOWN) {
+ return springAiCategory;
+ }
+ }
+
+ return GenAiErrorCategory.UNKNOWN;
+ }
+
+ private static GenAiErrorCategory classifyByHierarchy(Throwable error,
Map<String, GenAiErrorCategory> categories) {
+ Class<?> type = error.getClass();
+ while (type != null && Throwable.class.isAssignableFrom(type)) {
+ GenAiErrorCategory category = categories.get(type.getName());
+ if (category != null) {
+ return category;
+ }
+ type = type.getSuperclass();
+ }
+ return GenAiErrorCategory.UNKNOWN;
+ }
+
+ private static GenAiErrorCategory fromHttpStatus(int statusCode) {
+ if (statusCode == 429) {
+ return GenAiErrorCategory.RATE_LIMIT;
+ }
+ if (statusCode == 401 || statusCode == 403) {
+ return GenAiErrorCategory.AUTH;
+ }
+ if (statusCode == 408 || statusCode >= 500) {
+ return GenAiErrorCategory.SERVER_ERROR;
+ }
+ if (statusCode >= 400) {
+ return GenAiErrorCategory.VALIDATION;
+ }
+ return GenAiErrorCategory.UNKNOWN;
+ }
+
+ private static Long extractOpenAiRetryAfterMillis(Throwable error) {
+ if (!error.getClass().getName().startsWith("com.openai.errors.")) {
+ return null;
+ }
+ try {
+ Method headersMethod = error.getClass().getMethod("headers");
+ Object headers = headersMethod.invoke(error);
+ if (headers == null) {
+ return null;
+ }
+ Long retryAfterMs = readRetryAfterMillisHeader(headers);
+ if (retryAfterMs != null) {
+ return retryAfterMs;
+ }
+ return readRetryAfterSecondsHeader(headers);
+ } catch (ReflectiveOperationException ignored) {
+ // OpenAI SDK not present or API changed
+ }
+ return null;
+ }
+
+ private static Long readRetryAfterMillisHeader(Object headers) throws
ReflectiveOperationException {
+ Method valuesMethod = headers.getClass().getMethod("values",
String.class);
+ Method namesMethod = headers.getClass().getMethod("names");
+ Object namesObject = namesMethod.invoke(headers);
+ if (!(namesObject instanceof Set<?> names)) {
+ return readNamedHeaderValues(valuesMethod, headers,
RETRY_AFTER_MS_HEADER_NAMES);
+ }
+ for (Object nameObject : names) {
+ if (nameObject == null) {
+ continue;
+ }
+ String name = nameObject.toString();
+ if (!isRetryAfterMsHeader(name)) {
+ continue;
+ }
+ Long parsed = readFirstHeaderValue(valuesMethod, headers, name);
+ if (parsed != null) {
+ return parsed;
+ }
+ }
+ return readNamedHeaderValues(valuesMethod, headers,
RETRY_AFTER_MS_HEADER_NAMES);
+ }
+
+ private static Long readRetryAfterSecondsHeader(Object headers) throws
ReflectiveOperationException {
+ Method valuesMethod = headers.getClass().getMethod("values",
String.class);
+ Method namesMethod = headers.getClass().getMethod("names");
+ Object namesObject = namesMethod.invoke(headers);
+ if (namesObject instanceof Set<?> names) {
+ for (Object nameObject : names) {
+ if (nameObject == null) {
+ continue;
+ }
+ String name = nameObject.toString();
+ if (!isRetryAfterHeader(name)) {
+ continue;
+ }
+ Long parsed =
parseRetryAfterSeconds(readRawHeaderValue(valuesMethod, headers, name));
+ if (parsed != null) {
+ return parsed;
+ }
+ }
+ }
+ return readNamedHeaderValues(valuesMethod, headers,
RETRY_AFTER_HEADER_NAMES);
+ }
+
+ private static Long readNamedHeaderValues(Method valuesMethod, Object
headers, Set<String> headerNames)
+ throws ReflectiveOperationException {
+ for (String headerName : headerNames) {
+ Long parsed = readFirstHeaderValue(valuesMethod, headers,
headerName);
+ if (parsed != null) {
+ return parsed;
+ }
+ }
+ return null;
+ }
+
+ private static Long readFirstHeaderValue(Method valuesMethod, Object
headers, String headerName)
+ throws ReflectiveOperationException {
+ if (isRetryAfterMsHeader(headerName)) {
+ return parseRetryAfterMillis(readRawHeaderValue(valuesMethod,
headers, headerName));
+ }
+ return parseRetryAfterSeconds(readRawHeaderValue(valuesMethod,
headers, headerName));
+ }
+
+ private static Object readRawHeaderValue(Method valuesMethod, Object
headers, String headerName)
+ throws ReflectiveOperationException {
+ Object valuesObject = valuesMethod.invoke(headers, headerName);
+ if (!(valuesObject instanceof List<?> values) || values.isEmpty()) {
+ return null;
+ }
+ return values.get(0);
+ }
+
+ private static boolean isRetryAfterMsHeader(String headerName) {
+ return "retry-after-ms".equals(headerName.toLowerCase(Locale.ROOT));
+ }
+
+ private static boolean isRetryAfterHeader(String headerName) {
+ return "retry-after".equals(headerName.toLowerCase(Locale.ROOT));
+ }
+
+ private static Long parseRetryAfterMillis(Object headerValue) {
+ if (headerValue == null) {
+ return null;
+ }
+ String value = headerValue.toString().trim();
+ if (value.isEmpty()) {
+ return null;
+ }
+ try {
+ long millis = Long.parseLong(value);
+ return millis < 0 ? null : millis;
+ } catch (NumberFormatException ignored) {
+ return null;
+ }
+ }
+
+ private static Long parseRetryAfterSeconds(Object headerValue) {
+ if (headerValue == null) {
+ return null;
+ }
+ String value = headerValue.toString().trim();
+ if (value.isEmpty()) {
+ return null;
+ }
+ try {
+ long seconds = Long.parseLong(value);
+ if (seconds < 0) {
+ return null;
+ }
+ if (seconds > Long.MAX_VALUE / 1000L) {
+ return Long.MAX_VALUE;
+ }
+ return seconds * 1000L;
+ } catch (NumberFormatException ignored) {
+ return null;
+ }
+ }
+
+ private static int invokeIntMethod(Throwable target, String methodName) {
+ try {
+ Method method = target.getClass().getMethod(methodName);
+ Object result = method.invoke(target);
+ if (result instanceof Number number) {
+ return number.intValue();
+ }
+ } catch (ReflectiveOperationException ignored) {
+ // ignore
+ }
+ return 0;
+ }
+}
diff --git
a/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java
b/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java
new file mode 100644
index 000000000000..1ce0b4ad46ba
--- /dev/null
+++
b/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiErrorSupportTest.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ai.observability;
+
+import dev.langchain4j.exception.AuthenticationException;
+import dev.langchain4j.exception.HttpException;
+import dev.langchain4j.exception.InternalServerException;
+import dev.langchain4j.exception.InvalidRequestException;
+import dev.langchain4j.exception.RateLimitException;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class GenAiErrorSupportTest extends CamelTestSupport {
+
+ @Test
+ void shouldClassifyLangChain4jRateLimitException() {
+ assertThat(GenAiErrorSupport.classify(new RateLimitException("quota
exceeded")))
+ .isEqualTo(GenAiErrorCategory.RATE_LIMIT);
+ }
+
+ @Test
+ void shouldClassifyLangChain4jAuthenticationException() {
+ assertThat(GenAiErrorSupport.classify(new
AuthenticationException("invalid key")))
+ .isEqualTo(GenAiErrorCategory.AUTH);
+ }
+
+ @Test
+ void shouldClassifyLangChain4jValidationExceptions() {
+ assertThat(GenAiErrorSupport.classify(new InvalidRequestException("bad
request")))
+ .isEqualTo(GenAiErrorCategory.VALIDATION);
+ }
+
+ @Test
+ void shouldClassifyLangChain4jHttpStatusCodes() {
+ assertThat(GenAiErrorSupport.classify(new HttpException(429, "too many
requests")))
+ .isEqualTo(GenAiErrorCategory.RATE_LIMIT);
+ assertThat(GenAiErrorSupport.classify(new HttpException(401,
"unauthorized")))
+ .isEqualTo(GenAiErrorCategory.AUTH);
+ assertThat(GenAiErrorSupport.classify(new HttpException(503,
"unavailable")))
+ .isEqualTo(GenAiErrorCategory.SERVER_ERROR);
+ assertThat(GenAiErrorSupport.classify(new HttpException(408,
"timeout")))
+ .isEqualTo(GenAiErrorCategory.SERVER_ERROR);
+ assertThat(GenAiErrorSupport.classify(new HttpException(400, "bad
request")))
+ .isEqualTo(GenAiErrorCategory.VALIDATION);
+ }
+
+ @Test
+ void shouldClassifyLangChain4jInternalServerException() {
+ assertThat(GenAiErrorSupport.classify(new
InternalServerException("upstream failure")))
+ .isEqualTo(GenAiErrorCategory.SERVER_ERROR);
+ }
+
+ @Test
+ void shouldWalkCauseChain() {
+ RuntimeException wrapped = new RuntimeException("outer", new
RateLimitException("429"));
+
assertThat(GenAiErrorSupport.classify(wrapped)).isEqualTo(GenAiErrorCategory.RATE_LIMIT);
+ }
+
+ @Test
+ void shouldClassifySpringAiExceptionsByClassName() {
+ assertThat(GenAiErrorSupport.classify(new
org.springframework.ai.retry.TransientAiException("retry")))
+ .isEqualTo(GenAiErrorCategory.SERVER_ERROR);
+ assertThat(GenAiErrorSupport.classify(new
org.springframework.ai.retry.NonTransientAiException("fail")))
+ .isEqualTo(GenAiErrorCategory.VALIDATION);
+ }
+
+ @Test
+ void shouldPreferSpecificCauseOverSpringAiWrapper() {
+ assertThat(GenAiErrorSupport.classify(
+ new org.springframework.ai.retry.TransientAiException("retry",
new RateLimitException("429"))))
+ .isEqualTo(GenAiErrorCategory.RATE_LIMIT);
+ }
+
+ @Test
+ void shouldApplyCategoryPropertyToExchange() {
+ DefaultExchange exchange = new DefaultExchange(context);
+ GenAiErrorSupport.apply(exchange, new RateLimitException("quota
exceeded"));
+
+ assertThat(exchange.getProperty(GenAiErrorProperties.ERROR_CATEGORY,
String.class))
+ .isEqualTo(GenAiErrorCategory.RATE_LIMIT.name());
+
assertThat(exchange.getProperty(GenAiErrorProperties.RETRY_AFTER_MILLIS)).isNull();
+ }
+
+ @Test
+ void shouldReturnUnknownForUnrecognizedException() {
+ assertThat(GenAiErrorSupport.classify(new
IllegalStateException("boom")))
+ .isEqualTo(GenAiErrorCategory.UNKNOWN);
+ }
+}
diff --git
a/components/camel-ai/camel-ai-observability-api/src/test/java/org/springframework/ai/retry/NonTransientAiException.java
b/components/camel-ai/camel-ai-observability-api/src/test/java/org/springframework/ai/retry/NonTransientAiException.java
new file mode 100644
index 000000000000..4fa21330a362
--- /dev/null
+++
b/components/camel-ai/camel-ai-observability-api/src/test/java/org/springframework/ai/retry/NonTransientAiException.java
@@ -0,0 +1,26 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.ai.retry;
+
+/**
+ * Test stub matching Spring AI retry exception class name for classification
tests.
+ */
+public class NonTransientAiException extends RuntimeException {
+ public NonTransientAiException(String message) {
+ super(message);
+ }
+}
diff --git
a/components/camel-ai/camel-ai-observability-api/src/test/java/org/springframework/ai/retry/TransientAiException.java
b/components/camel-ai/camel-ai-observability-api/src/test/java/org/springframework/ai/retry/TransientAiException.java
new file mode 100644
index 000000000000..8cd5afa17bd8
--- /dev/null
+++
b/components/camel-ai/camel-ai-observability-api/src/test/java/org/springframework/ai/retry/TransientAiException.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.ai.retry;
+
+/**
+ * Test stub matching Spring AI retry exception class name for classification
tests.
+ */
+public class TransientAiException extends RuntimeException {
+ public TransientAiException(String message) {
+ super(message);
+ }
+
+ public TransientAiException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git
a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc
b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc
index 542581d694ff..a331982d11df 100644
---
a/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc
+++
b/components/camel-ai/camel-langchain4j-agent/src/main/docs/langchain4j-agent-component.adoc
@@ -1178,6 +1178,37 @@ You can also define the `ResponseFormat` at the
`ChatModel` level. See the https
* The same schema file can be shared across `camel-openai` and
`camel-langchain4j-agent` components
====
+[[structured_error_exchange_properties]]
+=== Structured error exchange properties
+
+When a LangChain4j agent call fails, Camel sets structured metadata on the
exchange **before** the model exception propagates. This works even when GenAI
observability is disabled.
+
+[cols="2,3"]
+|===
+| Exchange property | Meaning
+
+| `CamelAiErrorCategory` | Coarse category derived from the LangChain4j
exception: `RATE_LIMIT`, `SERVER_ERROR`, `VALIDATION`, `AUTH`, or `UNKNOWN`
+| `CamelAiRetryAfterMillis` | Not populated for LangChain4j providers
(OpenAI-only today)
+|===
+
+Category-based handling complements matching on LangChain4j exception types in
xref:langchain4j-chat-component.adoc#_error_handling[LangChain4j Chat error
handling]:
+
+[source, java]
+----
+onException(Exception.class)
+ .process(exchange -> {
+ String category = exchange.getProperty("CamelAiErrorCategory",
String.class);
+ if ("RATE_LIMIT".equals(category)) {
+ exchange.getIn().setHeader("RateLimited", true);
+ }
+ })
+ .maximumRedeliveries(3)
+ .handled(true)
+ .to("direct:rate-limited");
+----
+
+See
xref:ai-llm-integration-guide.adoc#_structured_error_exchange_properties[AI LLM
integration guide] for a cross-component overview.
+
== Sub-Pages
For more details on specific features, see:
diff --git
a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
index 07cdbaa69a47..97aa2560e81b 100644
---
a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
+++
b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java
@@ -49,6 +49,7 @@ import dev.langchain4j.service.tool.ToolProviderResult;
import org.apache.camel.Exchange;
import org.apache.camel.Message;
import org.apache.camel.RuntimeCamelException;
+import org.apache.camel.component.ai.observability.GenAiErrorSupport;
import org.apache.camel.component.ai.observability.GenAiModelResolver;
import org.apache.camel.component.ai.observability.GenAiObservability;
import org.apache.camel.component.ai.observability.GenAiObservation;
@@ -185,6 +186,7 @@ public class LangChain4jAgentProducer extends
DefaultProducer {
result.finishReason(),
null));
} catch (RuntimeException e) {
+ GenAiErrorSupport.apply(exchange, e);
observation.recordError(e);
throw e;
} finally {
diff --git
a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentErrorMetadataTest.java
b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentErrorMetadataTest.java
new file mode 100644
index 000000000000..18ad717cc716
--- /dev/null
+++
b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentErrorMetadataTest.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.langchain4j.agent;
+
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.langchain4j.exception.RateLimitException;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.ai.observability.GenAiErrorCategory;
+import org.apache.camel.component.ai.observability.GenAiErrorProperties;
+import
org.apache.camel.component.ai.observability.GenAiObservabilityProperties;
+import org.apache.camel.component.langchain4j.agent.api.Agent;
+import org.apache.camel.component.langchain4j.agent.api.AiAgentBody;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LangChain4jAgentErrorMetadataTest extends CamelTestSupport {
+
+ private final AtomicReference<Exchange> failedExchange = new
AtomicReference<>();
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("testAgent", (Agent) (body, toolProvider) -> {
+ throw new RateLimitException("quota exceeded");
+ });
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ Properties properties = new Properties();
+ properties.setProperty(GenAiObservabilityProperties.ENABLED,
"false");
+
context.getPropertiesComponent().setOverrideProperties(properties);
+
+ onException(RateLimitException.class)
+ .process(exchange -> failedExchange.set(exchange))
+ .handled(true);
+
+ from("direct:start")
+ .to("langchain4j-agent:test?agent=#testAgent")
+ .to("mock:result");
+ }
+ };
+ }
+
+ @Test
+ void shouldExposeErrorCategoryWhenObservabilityDisabled() throws Exception
{
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(0);
+
+ template.sendBody("direct:start", new AiAgentBody<>("Hello"));
+
+ mock.assertIsSatisfied(10, TimeUnit.SECONDS);
+
+ Exchange exchange = failedExchange.get();
+ assertThat(exchange).isNotNull();
+ assertThat(exchange.getProperty(GenAiErrorProperties.ERROR_CATEGORY,
String.class))
+ .isEqualTo(GenAiErrorCategory.RATE_LIMIT.name());
+
assertThat(exchange.getProperty(GenAiErrorProperties.RETRY_AFTER_MILLIS)).isNull();
+ }
+}
diff --git
a/components/camel-ai/camel-langchain4j-chat/src/main/docs/langchain4j-chat-component.adoc
b/components/camel-ai/camel-langchain4j-chat/src/main/docs/langchain4j-chat-component.adoc
index cbe442b39008..18e4ca3dc39e 100644
---
a/components/camel-ai/camel-langchain4j-chat/src/main/docs/langchain4j-chat-component.adoc
+++
b/components/camel-ai/camel-langchain4j-chat/src/main/docs/langchain4j-chat-component.adoc
@@ -367,6 +367,35 @@ from("direct:chat")
.to("langchain4j-chat:my-chat");
----
+=== Structured error exchange properties
+
+When a LangChain4j chat call fails, Camel sets structured metadata on the
exchange **before** the model exception propagates. This works even when GenAI
observability is disabled.
+
+[cols="2,3"]
+|===
+| Exchange property | Meaning
+
+| `CamelAiErrorCategory` | Coarse category derived from the LangChain4j
exception: `RATE_LIMIT`, `SERVER_ERROR`, `VALIDATION`, `AUTH`, or `UNKNOWN`
+| `CamelAiRetryAfterMillis` | Not populated for LangChain4j providers
(OpenAI-only today)
+|===
+
+Category-based handling complements matching on `RetriableException` /
`NonRetriableException` above:
+
+[source, java]
+----
+onException(Exception.class)
+ .process(exchange -> {
+ String category = exchange.getProperty("CamelAiErrorCategory",
String.class);
+ if ("VALIDATION".equals(category)) {
+ exchange.getIn().setHeader("Rejected", true);
+ }
+ })
+ .handled(true)
+ .to("direct:rejected");
+----
+
+The same properties are set by
xref:langchain4j-agent-component.adoc[LangChain4j Agent] and
xref:langchain4j-embeddings-component.adoc[LangChain4j Embeddings] on their
failure paths. See
xref:ai-llm-integration-guide.adoc#_structured_error_exchange_properties[AI LLM
integration guide] for a cross-component overview.
+
IMPORTANT: The mapping is performed by the LangChain4j model implementation,
not by Camel. `OpenAiChatModel` and `OllamaChatModel` route their calls through
LangChain4j's exception mapper, but a model that does not will propagate the
raw exception of its own HTTP client instead. Verify what your provider throws
before relying on the type.
=== Model retries vs Camel redelivery
diff --git
a/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java
b/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java
index 25b626d130fa..344de91051b4 100644
---
a/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java
+++
b/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java
@@ -34,6 +34,7 @@ import org.apache.camel.Exchange;
import org.apache.camel.InvalidPayloadException;
import org.apache.camel.Message;
import org.apache.camel.NoSuchHeaderException;
+import org.apache.camel.component.ai.observability.GenAiErrorSupport;
import org.apache.camel.component.ai.observability.GenAiModelResolver;
import org.apache.camel.component.ai.observability.GenAiObservability;
import org.apache.camel.component.ai.observability.GenAiObservation;
@@ -163,6 +164,7 @@ public class LangChain4jChatProducer extends
DefaultProducer {
exchange.getContext().getClassResolver(),
chatResponse, observationContext.requestModel())));
return extractAiResponse(chatResponse.aiMessage());
} catch (RuntimeException e) {
+ GenAiErrorSupport.apply(exchange, e);
observation.recordError(e);
throw e;
} finally {
@@ -226,6 +228,7 @@ public class LangChain4jChatProducer extends
DefaultProducer {
response = chatResponse.aiMessage();
return extractAiResponse(response);
} catch (RuntimeException e) {
+ GenAiErrorSupport.apply(exchange, e);
observation.recordError(e);
throw e;
} finally {
diff --git
a/components/camel-ai/camel-langchain4j-chat/src/test/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatErrorMetadataTest.java
b/components/camel-ai/camel-langchain4j-chat/src/test/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatErrorMetadataTest.java
new file mode 100644
index 000000000000..958a1929369d
--- /dev/null
+++
b/components/camel-ai/camel-langchain4j-chat/src/test/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatErrorMetadataTest.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.langchain4j.chat;
+
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.langchain4j.data.message.ChatMessage;
+import dev.langchain4j.exception.RateLimitException;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.response.ChatResponse;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.ai.observability.GenAiErrorCategory;
+import org.apache.camel.component.ai.observability.GenAiErrorProperties;
+import
org.apache.camel.component.ai.observability.GenAiObservabilityProperties;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LangChain4jChatErrorMetadataTest extends CamelTestSupport {
+
+ private final AtomicReference<Exchange> failedExchange = new
AtomicReference<>();
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("chatModel", new FailingChatModel());
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ Properties properties = new Properties();
+ properties.setProperty(GenAiObservabilityProperties.ENABLED,
"false");
+
context.getPropertiesComponent().setOverrideProperties(properties);
+
+ onException(RateLimitException.class)
+ .process(exchange -> failedExchange.set(exchange))
+ .handled(true);
+
+ from("direct:start")
+ .to("langchain4j-chat:test?chatModel=#chatModel")
+ .to("mock:result");
+ }
+ };
+ }
+
+ @Test
+ void shouldExposeErrorCategoryWhenObservabilityDisabled() throws Exception
{
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(0);
+
+ template.sendBody("direct:start", "Hello");
+
+ mock.assertIsSatisfied(10, TimeUnit.SECONDS);
+
+ Exchange exchange = failedExchange.get();
+ assertThat(exchange).isNotNull();
+ assertThat(exchange.getProperty(GenAiErrorProperties.ERROR_CATEGORY,
String.class))
+ .isEqualTo(GenAiErrorCategory.RATE_LIMIT.name());
+
assertThat(exchange.getProperty(GenAiErrorProperties.RETRY_AFTER_MILLIS)).isNull();
+ }
+
+ private static final class FailingChatModel implements ChatModel {
+ @Override
+ public ChatResponse chat(ChatRequest request) {
+ throw new RateLimitException("quota exceeded");
+ }
+
+ @Override
+ public ChatResponse chat(ChatMessage... messages) {
+ throw new RateLimitException("quota exceeded");
+ }
+ }
+}
diff --git
a/components/camel-ai/camel-langchain4j-embeddings/src/main/docs/langchain4j-embeddings-component.adoc
b/components/camel-ai/camel-langchain4j-embeddings/src/main/docs/langchain4j-embeddings-component.adoc
index a145699c1524..387d9e382f24 100644
---
a/components/camel-ai/camel-langchain4j-embeddings/src/main/docs/langchain4j-embeddings-component.adoc
+++
b/components/camel-ai/camel-langchain4j-embeddings/src/main/docs/langchain4j-embeddings-component.adoc
@@ -394,3 +394,18 @@ YAML::
returnTextContent: true
----
====
+
+[[structured_error_exchange_properties]]
+=== Structured error exchange properties
+
+When a LangChain4j embeddings call fails, Camel sets structured metadata on
the exchange **before** the model exception propagates. This works even when
GenAI observability is disabled.
+
+[cols="2,3"]
+|===
+| Exchange property | Meaning
+
+| `CamelAiErrorCategory` | Coarse category derived from the LangChain4j
exception: `RATE_LIMIT`, `SERVER_ERROR`, `VALIDATION`, `AUTH`, or `UNKNOWN`
+| `CamelAiRetryAfterMillis` | Not populated for LangChain4j providers
(OpenAI-only today)
+|===
+
+Use categories when a route should branch on failure type without matching
every LangChain4j exception class. See
xref:langchain4j-chat-component.adoc#_structured_error_exchange_properties[LangChain4j
Chat structured error properties] and
xref:ai-llm-integration-guide.adoc#_structured_error_exchange_properties[AI LLM
integration guide] for related detail.
diff --git
a/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java
b/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java
index 7bb7da5c02d3..c34424024f91 100644
---
a/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java
+++
b/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java
@@ -22,6 +22,7 @@ import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.output.Response;
import org.apache.camel.Exchange;
import org.apache.camel.Message;
+import org.apache.camel.component.ai.observability.GenAiErrorSupport;
import org.apache.camel.component.ai.observability.GenAiModelResolver;
import org.apache.camel.component.ai.observability.GenAiObservability;
import org.apache.camel.component.ai.observability.GenAiObservation;
@@ -60,6 +61,7 @@ public class LangChain4jEmbeddingsProducer extends
DefaultProducer {
result.finishReason(),
null));
} catch (RuntimeException e) {
+ GenAiErrorSupport.apply(exchange, e);
observation.recordError(e);
throw e;
} finally {
diff --git
a/components/camel-ai/camel-langchain4j-embeddings/src/test/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsErrorMetadataTest.java
b/components/camel-ai/camel-langchain4j-embeddings/src/test/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsErrorMetadataTest.java
new file mode 100644
index 000000000000..5bee26554bd4
--- /dev/null
+++
b/components/camel-ai/camel-langchain4j-embeddings/src/test/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsErrorMetadataTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.langchain4j.embeddings;
+
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.langchain4j.data.embedding.Embedding;
+import dev.langchain4j.data.segment.TextSegment;
+import dev.langchain4j.exception.RateLimitException;
+import dev.langchain4j.model.ModelProvider;
+import dev.langchain4j.model.embedding.EmbeddingModel;
+import dev.langchain4j.model.output.Response;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.ai.observability.GenAiErrorCategory;
+import org.apache.camel.component.ai.observability.GenAiErrorProperties;
+import
org.apache.camel.component.ai.observability.GenAiObservabilityProperties;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LangChain4jEmbeddingsErrorMetadataTest extends CamelTestSupport {
+
+ private final AtomicReference<Exchange> failedExchange = new
AtomicReference<>();
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("embeddingModel", new FailingEmbeddingModel());
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ Properties properties = new Properties();
+ properties.setProperty(GenAiObservabilityProperties.ENABLED,
"false");
+
context.getPropertiesComponent().setOverrideProperties(properties);
+
+ onException(RateLimitException.class)
+ .process(exchange -> failedExchange.set(exchange))
+ .handled(true);
+
+ from("direct:start")
+
.to("langchain4j-embeddings:test?embeddingModel=#embeddingModel")
+ .to("mock:result");
+ }
+ };
+ }
+
+ @Test
+ void shouldExposeErrorCategoryWhenObservabilityDisabled() throws Exception
{
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(0);
+
+ template.sendBody("direct:start", TextSegment.from("Hello"));
+
+ mock.assertIsSatisfied(10, TimeUnit.SECONDS);
+
+ Exchange exchange = failedExchange.get();
+ assertThat(exchange).isNotNull();
+ assertThat(exchange.getProperty(GenAiErrorProperties.ERROR_CATEGORY,
String.class))
+ .isEqualTo(GenAiErrorCategory.RATE_LIMIT.name());
+
assertThat(exchange.getProperty(GenAiErrorProperties.RETRY_AFTER_MILLIS)).isNull();
+ }
+
+ private static final class FailingEmbeddingModel implements EmbeddingModel
{
+ @Override
+ public ModelProvider provider() {
+ return ModelProvider.OPEN_AI;
+ }
+
+ @Override
+ public String modelName() {
+ return "text-embedding-3-small";
+ }
+
+ @Override
+ public Response<Embedding> embed(TextSegment textSegment) {
+ throw new RateLimitException("quota exceeded");
+ }
+ }
+}
diff --git
a/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc
b/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc
index e969ff2d3f46..90a32cae5157 100644
--- a/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc
+++ b/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc
@@ -1366,6 +1366,38 @@ onException(OpenAIServiceException.class)
`headers()` on the same exception exposes the response headers, including
`Retry-After` on a 429.
+=== Structured error exchange properties
+
+When an OpenAI producer call fails, Camel sets structured metadata on the
exchange **before** the SDK exception propagates. This works even when GenAI
observability is disabled.
+
+[cols="2,3"]
+|===
+| Exchange property | Meaning
+
+| `CamelAiErrorCategory` | Coarse category: `RATE_LIMIT`, `SERVER_ERROR`,
`VALIDATION`, `AUTH`, or `UNKNOWN`
+| `CamelAiRetryAfterMillis` | Suggested retry delay in milliseconds when the
OpenAI SDK exposes `Retry-After` or `Retry-After-Ms` on a 429; absent for other
providers and error types
+|===
+
+Use these when a route should branch on category without matching every SDK
type, or when you want a single `onException` policy across OpenAI,
LangChain4j, and Spring AI producers:
+
+[source,java]
+----
+onException(Exception.class)
+ .process(exchange -> {
+ String category = exchange.getProperty("CamelAiErrorCategory",
String.class);
+ Long retryAfter = exchange.getProperty("CamelAiRetryAfterMillis",
Long.class);
+ if ("RATE_LIMIT".equals(category)) {
+ long delay = retryAfter != null ? retryAfter : 2000L;
+ exchange.getIn().setHeader("RetryDelay", delay);
+ }
+ })
+ .maximumRedeliveries(3)
+ .handled(true)
+ .to("direct:rate-limited");
+----
+
+The SDK exception is still available as `Exchange.EXCEPTION_CAUGHT`, so you
can combine coarse categories with the fine-grained types in
<<_targeting_sdk_exceptions_with_onexception>>.
+
=== SDK Retry vs Camel Redelivery
The SDK client retries on its own before the exception ever reaches Camel. The
`maxRetries` option controls this and defaults to `2`, so one exchange already
issues up to three HTTP requests. The SDK retries 408, 409, 429 and 5xx
responses as well as connection failures, backing off exponentially and
honoring the `Retry-After` and `Retry-After-Ms` response headers.
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
index ec2fe6c776e2..04b6cbafed05 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
@@ -58,6 +58,7 @@ import org.apache.camel.CamelExchangeException;
import org.apache.camel.Exchange;
import org.apache.camel.Message;
import org.apache.camel.WrappedFile;
+import org.apache.camel.component.ai.observability.GenAiErrorSupport;
import org.apache.camel.component.ai.observability.GenAiObservability;
import org.apache.camel.component.ai.observability.GenAiObservation;
import org.apache.camel.component.ai.observability.GenAiObservationContext;
@@ -142,6 +143,7 @@ public class OpenAIProducer extends DefaultAsyncProducer {
callback.done(true);
return true;
} catch (Exception e) {
+ GenAiErrorSupport.apply(exchange, e);
exchange.setException(e);
callback.done(true);
return true;
@@ -626,9 +628,17 @@ public class OpenAIProducer extends DefaultAsyncProducer {
.build();
GenAiObservation observation = GenAiObservability.start(exchange,
observationContext);
- // NOTE: the stream is going to be closed after the exchange completes.
- StreamResponse<ChatCompletionChunk> streamResponse =
getEndpoint().getClient().chat().completions() // NOSONAR
- .createStreaming(streamingParams);
+ StreamResponse<ChatCompletionChunk> streamResponse;
+ try {
+ // NOTE: the stream is going to be closed after the exchange
completes.
+ streamResponse = getEndpoint().getClient().chat().completions() //
NOSONAR
+ .createStreaming(streamingParams);
+ } catch (RuntimeException e) {
+ GenAiErrorSupport.apply(exchange, e);
+ observation.recordError(e);
+ observation.close();
+ throw e;
+ }
AtomicReference<CompletionUsage> usageRef = new AtomicReference<>();
AtomicReference<String> responseModelRef = new AtomicReference<>();
@@ -636,17 +646,27 @@ public class OpenAIProducer extends DefaultAsyncProducer {
Iterator<ChatCompletionChunk> it = new Iterator<>() {
@Override
public boolean hasNext() {
- return delegate.hasNext();
+ try {
+ return delegate.hasNext();
+ } catch (RuntimeException e) {
+ GenAiErrorSupport.apply(exchange, e);
+ throw e;
+ }
}
@Override
public ChatCompletionChunk next() {
- ChatCompletionChunk chunk = delegate.next();
- chunk.usage().ifPresent(usageRef::set);
- if (chunk.model() != null && !chunk.model().isBlank()) {
- responseModelRef.set(chunk.model());
+ try {
+ ChatCompletionChunk chunk = delegate.next();
+ chunk.usage().ifPresent(usageRef::set);
+ if (chunk.model() != null && !chunk.model().isBlank()) {
+ responseModelRef.set(chunk.model());
+ }
+ return chunk;
+ } catch (RuntimeException e) {
+ GenAiErrorSupport.apply(exchange, e);
+ throw e;
}
- return chunk;
}
};
@@ -671,6 +691,7 @@ public class OpenAIProducer extends DefaultAsyncProducer {
@Override
public void onFailure(Exchange e) {
if (e.getException() != null) {
+ GenAiErrorSupport.apply(e, e.getException());
observation.recordError(e.getException());
}
observation.close();
@@ -733,6 +754,7 @@ public class OpenAIProducer extends DefaultAsyncProducer {
response.model()));
return response;
} catch (RuntimeException e) {
+ GenAiErrorSupport.apply(exchange, e);
observation.recordError(e);
throw e;
} finally {
diff --git
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIErrorMetadataTest.java
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIErrorMetadataTest.java
new file mode 100644
index 000000000000..04cc406c9426
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIErrorMetadataTest.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.openai;
+
+import com.openai.core.http.Headers;
+import com.openai.errors.RateLimitException;
+import com.openai.errors.UnauthorizedException;
+import org.apache.camel.component.ai.observability.GenAiErrorCategory;
+import org.apache.camel.component.ai.observability.GenAiErrorProperties;
+import org.apache.camel.component.ai.observability.GenAiErrorSupport;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OpenAIErrorMetadataTest extends CamelTestSupport {
+
+ @Test
+ void shouldClassifyOpenAiRateLimitException() {
+ RateLimitException error = RateLimitException.builder()
+ .headers(Headers.builder().put("Retry-After-Ms",
"1500").build())
+ .build();
+
+
assertThat(GenAiErrorSupport.classify(error)).isEqualTo(GenAiErrorCategory.RATE_LIMIT);
+
assertThat(GenAiErrorSupport.extractRetryAfterMillis(error)).isEqualTo(1500L);
+ }
+
+ @Test
+ void shouldParseRetryAfterSecondsHeader() {
+ RateLimitException error = RateLimitException.builder()
+ .headers(Headers.builder().put("Retry-After", "12").build())
+ .build();
+
+
assertThat(GenAiErrorSupport.extractRetryAfterMillis(error)).isEqualTo(12_000L);
+ }
+
+ @Test
+ void shouldClassifyOpenAiUnauthorizedException() {
+ UnauthorizedException error = UnauthorizedException.builder()
+ .headers(Headers.builder().build())
+ .build();
+
assertThat(GenAiErrorSupport.classify(error)).isEqualTo(GenAiErrorCategory.AUTH);
+ assertThat(GenAiErrorSupport.extractRetryAfterMillis(error)).isNull();
+ }
+
+ @Test
+ void shouldApplyOpenAiErrorPropertiesToExchange() {
+ RateLimitException error = RateLimitException.builder()
+ .headers(Headers.builder().put("Retry-After", "5").build())
+ .build();
+ DefaultExchange exchange = new DefaultExchange(context);
+
+ GenAiErrorSupport.apply(exchange, error);
+
+ assertThat(exchange.getProperty(GenAiErrorProperties.ERROR_CATEGORY,
String.class))
+ .isEqualTo(GenAiErrorCategory.RATE_LIMIT.name());
+
assertThat(exchange.getProperty(GenAiErrorProperties.RETRY_AFTER_MILLIS,
Long.class))
+ .isEqualTo(5_000L);
+ }
+}
diff --git a/components/camel-ai/src/main/docs/ai-llm-integration-guide.adoc
b/components/camel-ai/src/main/docs/ai-llm-integration-guide.adoc
index 1c793a0bbcd8..15218aaad5ee 100644
--- a/components/camel-ai/src/main/docs/ai-llm-integration-guide.adoc
+++ b/components/camel-ai/src/main/docs/ai-llm-integration-guide.adoc
@@ -341,6 +341,35 @@ onException(NonRetriableException.class)
WARNING: These SDKs retry internally before Camel sees the failure, twice by
default, and Camel redelivery multiplies with that layer instead of replacing
it. Three SDK attempts under `maximumRedeliveries(3)` means twelve requests to
a provider that is already rate limiting you. Either turn the SDK layer off
(`maxRetries=0` on the OpenAI component, `maxRetries(0)` on a LangChain4j model
builder) so the retry is visible to the Camel error handler, or leave it on and
skip Camel redelivery [...]
+[[structured_error_exchange_properties]]
+=== Structured error exchange properties
+
+All LangChain4j-based producers (`langchain4j-chat`, `langchain4j-agent`,
`langchain4j-embeddings`), the OpenAI component, and Spring AI chat set two
exchange properties on failure **before** the underlying exception propagates.
Classification is independent of GenAI observability — it works when
observability is disabled or `camel-ai-observability` is not on the classpath.
+
+[cols="2,3"]
+|===
+| Exchange property | Meaning
+
+| `CamelAiErrorCategory` | Coarse category: `RATE_LIMIT`, `SERVER_ERROR`,
`VALIDATION`, `AUTH`, or `UNKNOWN`
+| `CamelAiRetryAfterMillis` | Retry delay in milliseconds parsed from OpenAI
`Retry-After` / `Retry-After-Ms` headers; populated for OpenAI SDK failures only
+|===
+
+Use categories when one error handler should cover every AI component on a
route. Keep matching on SDK exception types when you need provider-specific
detail — both approaches compose:
+
+[source,java]
+----
+onException(RetriableException.class, RateLimitException.class)
+ .process(exchange -> {
+ String category = exchange.getProperty("CamelAiErrorCategory",
String.class);
+ Long retryAfter = exchange.getProperty("CamelAiRetryAfterMillis",
Long.class);
+ // category is RATE_LIMIT; retryAfter set only for OpenAI
+ })
+ .maximumRedeliveries(3)
+ .redeliveryDelay(2000);
+----
+
+For OpenAI-specific detail see
xref:openai-component.adoc#_structured_error_exchange_properties[OpenAI
structured error properties]. For LangChain4j chat see
xref:langchain4j-chat-component.adoc#_structured_error_exchange_properties[LangChain4j
Chat structured error properties]. For LangChain4j agent and embeddings see
xref:langchain4j-agent-component.adoc#_structured_error_exchange_properties[LangChain4j
Agent structured error properties] and
xref:langchain4j-embeddings-component.adoc#_ [...]
+
For the per-component detail see
xref:openai-component.adoc#_targeting_sdk_exceptions_with_onexception[OpenAI
error handling] and
xref:langchain4j-chat-component.adoc#_error_handling[LangChain4j Chat error
handling].
== End-to-end example: document extraction pipeline
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc
index d2fd642a768a..c8d8c0a84040 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc
@@ -1407,6 +1407,37 @@ The component automatically adds Spring AI's
`SimpleLoggerAdvisor` to log reques
logging.level.org.springframework.ai.chat.client.advisor=DEBUG
----
+[[structured_error_exchange_properties]]
+=== Structured error exchange properties
+
+When a Spring AI chat call fails, Camel sets structured metadata on the
exchange **before** the Spring AI exception propagates. This works even when
GenAI observability is disabled.
+
+[cols="2,3"]
+|===
+| Exchange property | Meaning
+
+| `CamelAiErrorCategory` | Coarse category derived from Spring AI retry
exceptions: `SERVER_ERROR` for `TransientAiException`, `VALIDATION` for
`NonTransientAiException`, or `UNKNOWN` when no mapping applies
+| `CamelAiRetryAfterMillis` | Not populated for Spring AI providers
(OpenAI-only today)
+|===
+
+Category-based handling complements matching on Spring AI exception types
directly:
+
+[source,java]
+----
+onException(Exception.class)
+ .process(exchange -> {
+ String category = exchange.getProperty("CamelAiErrorCategory",
String.class);
+ if ("SERVER_ERROR".equals(category)) {
+ exchange.getIn().setHeader("Retryable", true);
+ }
+ })
+ .maximumRedeliveries(3)
+ .handled(true)
+ .to("direct:retry");
+----
+
+See
xref:ai-llm-integration-guide.adoc#_structured_error_exchange_properties[AI LLM
integration guide] for a cross-component overview.
+
== See Also
* https://docs.spring.io/spring-ai/reference/[Spring AI Documentation]
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
index a88ad48fd972..2ffb1d7e2f32 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
@@ -33,6 +33,7 @@ import org.apache.camel.Exchange;
import org.apache.camel.InvalidPayloadException;
import org.apache.camel.NoSuchHeaderException;
import org.apache.camel.WrappedFile;
+import org.apache.camel.component.ai.observability.GenAiErrorSupport;
import org.apache.camel.component.ai.observability.GenAiModelResolver;
import org.apache.camel.component.ai.observability.GenAiObservability;
import org.apache.camel.component.ai.observability.GenAiObservation;
@@ -881,6 +882,7 @@ public class SpringAiChatProducer extends DefaultProducer {
exchange.getMessage().setBody(entity);
LOG.debug("Converted response to entity of type: {}",
entityClass.getName());
} catch (RuntimeException e) {
+ GenAiErrorSupport.apply(exchange, e);
observation.recordError(e);
throw e;
} finally {
@@ -1240,6 +1242,7 @@ public class SpringAiChatProducer extends DefaultProducer
{
recordObservationSuccess(observation, response,
observationContext.requestModel());
return response;
} catch (RuntimeException e) {
+ GenAiErrorSupport.apply(exchange, e);
observation.recordError(e);
throw e;
} finally {
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatErrorMetadataTest.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatErrorMetadataTest.java
new file mode 100644
index 000000000000..c30411ab9096
--- /dev/null
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatErrorMetadataTest.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.springai.chat;
+
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.ai.observability.GenAiErrorCategory;
+import org.apache.camel.component.ai.observability.GenAiErrorProperties;
+import
org.apache.camel.component.ai.observability.GenAiObservabilityProperties;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.prompt.Prompt;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SpringAiChatErrorMetadataTest extends CamelTestSupport {
+
+ private final AtomicReference<Exchange> failedExchange = new
AtomicReference<>();
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ Properties properties = new Properties();
+ properties.setProperty(GenAiObservabilityProperties.ENABLED,
"false");
+
context.getPropertiesComponent().setOverrideProperties(properties);
+
+ onException(RuntimeException.class)
+ .process(exchange -> failedExchange.set(exchange))
+ .handled(true);
+
+ from("direct:start")
+ .to("spring-ai-chat:test")
+ .to("mock:result");
+ }
+ };
+ }
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ SpringAiChatComponent component = new SpringAiChatComponent();
+ component.setChatModel(new FailingChatModel());
+ context.addComponent("spring-ai-chat", component);
+ return context;
+ }
+
+ @Test
+ void shouldExposeErrorCategoryWhenObservabilityDisabled() throws Exception
{
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedMessageCount(0);
+
+ template.sendBody("direct:start", "Hello");
+
+ mock.assertIsSatisfied(10, TimeUnit.SECONDS);
+
+ Exchange exchange = failedExchange.get();
+ assertThat(exchange).isNotNull();
+ assertThat(exchange.getProperty(GenAiErrorProperties.ERROR_CATEGORY,
String.class))
+ .isEqualTo(GenAiErrorCategory.SERVER_ERROR.name());
+ }
+
+ private static final class FailingChatModel implements ChatModel {
+ @Override
+ public ChatResponse call(Prompt prompt) {
+ throw new
org.springframework.ai.retry.TransientAiException("temporary upstream failure");
+ }
+ }
+}