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");
+        }
+    }
+}

Reply via email to