Skip to content

Commit 9e8dcb9

Browse files
committed
improvements to retry feature
1 parent deb01fd commit 9e8dcb9

11 files changed

Lines changed: 212 additions & 85 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
- Fix MCP OAuth credentials cache not invalidating when the server URL changes.
66
- Add `isSubagent` condition variable for chat system instructions
7+
- **Breaking:** Replace `bodyPattern` with `errorPattern` in `retryRules`, which matches against any error text (response body, error message, or exception message).
8+
- Retry on "Remote host terminated the handshake" TLS errors.
9+
- Fix empty "Error: " message on connection failures (e.g. DNS resolution, connection refused) and retry them as transient errors.
710

811
## 0.112.0
912

docs/config.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -477,8 +477,8 @@
477477
},
478478
"retryRules": {
479479
"type": "array",
480-
"description": "Custom retry rules. Each rule can match by HTTP status code and/or response body regex pattern. When matched, the request is retried with exponential backoff.",
481-
"markdownDescription": "Custom retry rules. Each rule can match by HTTP status code and/or response body regex pattern. When matched, the request is retried with exponential backoff.",
480+
"description": "Custom retry rules. Each rule can match by HTTP status code and/or error text regex pattern. When matched, the request is retried with exponential backoff.",
481+
"markdownDescription": "Custom retry rules. Each rule can match by HTTP status code and/or error text regex pattern. When matched, the request is retried with exponential backoff.",
482482
"items": {
483483
"type": "object",
484484
"properties": {
@@ -487,10 +487,10 @@
487487
"description": "HTTP status code to match.",
488488
"markdownDescription": "HTTP status code to match."
489489
},
490-
"bodyPattern": {
490+
"errorPattern": {
491491
"type": "string",
492-
"description": "Regex pattern to match against the response body (case-insensitive).",
493-
"markdownDescription": "Regex pattern to match against the response body (case-insensitive)."
492+
"description": "Regex pattern to match against any error text — response body, error message, or exception message (case-insensitive). Useful for connection-level errors that have no HTTP status.",
493+
"markdownDescription": "Regex pattern to match against any error text — response body, error message, or exception message (case-insensitive). Useful for connection-level errors that have no HTTP status."
494494
},
495495
"label": {
496496
"type": "string",
@@ -500,7 +500,7 @@
500500
},
501501
"anyOf": [
502502
{ "required": ["status"] },
503-
{ "required": ["bodyPattern"] }
503+
{ "required": ["errorPattern"] }
504504
],
505505
"additionalProperties": false
506506
}

docs/config/models.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Schema:
7878
| `thinkTagStart` | string | Optional override the think start tag tag for openai-chat (Default: "<think>") api | No |
7979
| `thinkTagEnd` | string | Optional override the think end tag for openai-chat (Default: "</think>") api | No |
8080
| `httpClient` | map | Allow customize the http-client for this provider requests, like changing http version | No |
81-
| `retryRules` | array | Custom retry rules that match by HTTP status and/or body regex pattern (see [Retry Rules](#retry-rules)) | No |
81+
| `retryRules` | array | Custom retry rules that match by HTTP status and/or error pattern (see [Retry Rules](#retry-rules)) | No |
8282
| `models` | map | Key: model name, value: its config | Yes |
8383
| `models <model> extraPayload` | map | Extra payload sent in body to LLM | No |
8484
| `models <model> extraHeaders` | map | Extra headers sent to LLM request | No |
@@ -233,15 +233,15 @@ Notes:
233233

234234
### Retry Rules
235235

236-
ECA automatically retries requests on common transient errors (429, 500, 502, 503, 529) with exponential backoff. You can define custom retry rules per provider using `retryRules` to handle additional status codes or response body patterns.
236+
ECA automatically retries requests on common transient errors (429, 500, 502, 503, 529) with exponential backoff. You can define custom retry rules per provider using `retryRules` to handle additional status codes or error patterns.
237237

238238
Each rule can match by:
239239

240240
- **`status`** (integer): HTTP status code to match
241-
- **`bodyPattern`** (string): Regex pattern to match against the response body (case-insensitive)
241+
- **`errorPattern`** (string): Regex pattern to match against any error text — response body, error message, or exception message (case-insensitive). Useful for both HTTP response errors and connection-level errors (e.g. TLS handshake failures)
242242
- **`label`** (string, optional): Human-readable text shown in the retry progress message
243243

244-
At least one of `status` or `bodyPattern` is required. When both are specified, both must match. Custom rules are checked before built-in classification, so they can override default behavior.
244+
At least one of `status` or `errorPattern` is required. When both are specified, both must match. Custom rules are checked before built-in classification, so they can override default behavior.
245245

246246
```javascript title="~/.config/eca/config.json"
247247
{
@@ -252,8 +252,9 @@ At least one of `status` or `bodyPattern` is required. When both are specified,
252252
"key": "${env:MY_COMPANY_API_KEY}",
253253
"retryRules": [
254254
{"status": 418, "label": "Corporate proxy throttle"},
255-
{"bodyPattern": "capacity.*exceeded", "label": "Capacity exceeded"},
256-
{"status": 503, "bodyPattern": "maintenance", "label": "Under maintenance"}
255+
{"errorPattern": "capacity.*exceeded", "label": "Capacity exceeded"},
256+
{"status": 503, "errorPattern": "maintenance", "label": "Under maintenance"},
257+
{"errorPattern": "terminated.*handshake", "label": "TLS handshake failed"}
257258
],
258259
"models": {
259260
"gpt-5": {}

src/eca/features/chat.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,11 +1473,11 @@
14731473
(do
14741474
(when compacting?
14751475
(swap! db* update-in [:chats chat-id] dissoc :auto-compacting? :compacting?))
1476-
(send-content! chat-ctx :system {:type :text :text (or message (str "Error: " (ex-message exception)))})
1476+
(send-content! chat-ctx :system {:type :text :text (or message (str "Error: " (or (ex-message exception) (.getName (class exception)))))})
14771477
(finish-chat-prompt! :idle (dissoc chat-ctx :on-finished-side-effect))))))})
14781478
(catch Exception e
14791479
(logger/error e)
1480-
(send-content! chat-ctx :system {:type :text :text (str "Error: " (ex-message e))})
1480+
(send-content! chat-ctx :system {:type :text :text (str "Error: " (or (ex-message e) (.getName (class e))))})
14811481
(finish-chat-prompt! :idle (dissoc chat-ctx :on-finished-side-effect))))))))))
14821482

14831483
(defn ^:private send-mcp-prompt!

src/eca/llm_providers/anthropic.clj

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,14 @@
8888
(llm-util/log-response logger-tag rid "response-error" body)
8989
(reset! response* error-data)))]
9090
(llm-util/log-request logger-tag rid url body headers)
91-
(let [{:keys [status body]} (http/post
92-
url
93-
{:headers headers
94-
:body (json/generate-string body)
95-
:throw-exceptions? false
96-
:http-client (client/merge-with-global-http-client http-client)
97-
:as (if on-stream :stream :json)})]
98-
(try
91+
(try
92+
(let [{:keys [status body]} (http/post
93+
url
94+
{:headers headers
95+
:body (json/generate-string body)
96+
:throw-exceptions? false
97+
:http-client (client/merge-with-global-http-client http-client)
98+
:as (if on-stream :stream :json)})]
9999
(if (not= 200 status)
100100
(let [body-str (if on-stream (slurp body) body)]
101101
(logger/warn logger-tag "Unexpected response status: %s body: %s" status body-str)
@@ -110,9 +110,10 @@
110110
(do
111111
(llm-util/log-response logger-tag rid "response" body)
112112
(reset! response*
113-
{:output-text (:text (last (:content body)))}))))
114-
(catch Exception e
115-
(on-error {:exception e}))))
113+
{:output-text (:text (last (:content body)))})))))
114+
(catch Exception e
115+
(on-error {:exception e
116+
:message (format "Connection error: %s" (or (ex-message e) (.getName (class e))))})))
116117
@response*))
117118

118119
(defn ^:private normalize-messages [past-messages supports-image?]

src/eca/llm_providers/errors.clj

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
#"(?i)throttl"
2626
#"(?i)overloaded_error"])
2727

28+
(def ^:private overloaded-patterns
29+
"Regex patterns matching transient connection/infrastructure errors across providers."
30+
[#"(?i)remote host terminated the handshake"
31+
#"(?i)host is unreachable"
32+
#"(?i)connection error"
33+
#"(?i)connection refused"
34+
#"(?i)UnresolvedAddressException"])
35+
2836
(defn ^:private matches-any-pattern? [^String text patterns]
2937
(when text
3038
(some #(re-find % text) patterns)))
@@ -66,24 +74,34 @@
6674
(matches-any-pattern? message rate-limited-patterns)
6775
{:error/type :rate-limited}
6876

77+
(matches-any-pattern? message overloaded-patterns)
78+
{:error/type :overloaded}
79+
6980
:else nil)))
7081

7182
(defn ^:private classify-by-custom-rules
7283
"Checks user-configured retry rules. Each rule may have :status (int),
73-
:body-pattern (regex string, case-insensitive), and :label (string).
84+
:errorPattern (regex string, case-insensitive, matched against body, message,
85+
and exception message), and :label (string).
7486
Returns {:error/type :retryable-custom :error/label label} on first match, nil otherwise."
75-
[{:keys [status body]} retry-rules]
87+
[{:keys [status body message exception]} retry-rules]
7688
(when (seq retry-rules)
77-
(some (fn [{rule-status :status rule-body-pattern :bodyPattern rule-label :label}]
89+
(some (fn [{rule-status :status rule-error-pattern :errorPattern rule-label :label}]
7890
(let [status-matches? (if rule-status
7991
(= rule-status status)
8092
true)
81-
body-matches? (if rule-body-pattern
82-
(when (string? body)
83-
(re-find (re-pattern (str "(?i)" rule-body-pattern)) body))
84-
true)
85-
has-condition? (or rule-status rule-body-pattern)]
86-
(when (and has-condition? status-matches? body-matches?)
93+
error-matches? (if rule-error-pattern
94+
(let [pattern (re-pattern (str "(?i)" rule-error-pattern))]
95+
(or (when (string? body)
96+
(re-find pattern body))
97+
(when (string? message)
98+
(re-find pattern message))
99+
(when exception
100+
(some-> (ex-message exception)
101+
(as-> msg (re-find pattern msg))))))
102+
true)
103+
has-condition? (or rule-status rule-error-pattern)]
104+
(when (and has-condition? status-matches? error-matches?)
87105
(cond-> {:error/type :retryable-custom}
88106
rule-label (assoc :error/label rule-label)))))
89107
retry-rules)))

src/eca/llm_providers/ollama.clj

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,14 @@
6565
(llm-util/log-response logger-tag rid "response-error" body)
6666
(reset! response* error-data)))]
6767
(llm-util/log-request logger-tag rid url body headers)
68-
(let [{:keys [status body]} (http/post
69-
url
70-
{:headers headers
71-
:body (json/generate-string body)
72-
:throw-exceptions? false
73-
:http-client (client/merge-with-global-http-client {})
74-
:as (if on-stream :stream :json)})]
75-
(try
68+
(try
69+
(let [{:keys [status body]} (http/post
70+
url
71+
{:headers headers
72+
:body (json/generate-string body)
73+
:throw-exceptions? false
74+
:http-client (client/merge-with-global-http-client {})
75+
:as (if on-stream :stream :json)})]
7676
(if (not= 200 status)
7777
(let [body-str (if on-stream (slurp body) body)]
7878
(logger/warn logger-tag (format "Unexpected response status: %s body: %s" status body-str))
@@ -87,9 +87,10 @@
8787
(do
8888
(llm-util/log-response logger-tag rid "response" body)
8989
(reset! response*
90-
{:output-text (:content (:message body))}))))
91-
(catch Exception e
92-
(on-error {:exception e}))))
90+
{:output-text (:content (:message body))})))))
91+
(catch Exception e
92+
(on-error {:exception e
93+
:message (format "Connection error: %s" (or (ex-message e) (.getName (class e))))})))
9394
@response*))
9495

9596
(defn ^:private ->tools [tools]

src/eca/llm_providers/openai.clj

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,14 @@
7272
(llm-util/log-response logger-tag rid "response-error" body)
7373
{:error error-data}))]
7474
(llm-util/log-request logger-tag rid url body headers)
75-
(let [{:keys [status body]} (http/post
76-
url
77-
{:headers headers
78-
:body (json/generate-string body)
79-
:throw-exceptions? false
80-
:http-client (client/merge-with-global-http-client http-client)
81-
:as (if on-stream :stream :json)})]
82-
(try
75+
(try
76+
(let [{:keys [status body]} (http/post
77+
url
78+
{:headers headers
79+
:body (json/generate-string body)
80+
:throw-exceptions? false
81+
:http-client (client/merge-with-global-http-client http-client)
82+
:as (if on-stream :stream :json)})]
8383
(if (not= 200 status)
8484
(let [body-str (if on-stream (slurp body) body)]
8585
(logger/warn logger-tag "Unexpected response status: %s body: %s" status body-str)
@@ -93,9 +93,10 @@
9393
(on-stream event data)))
9494
(do
9595
(llm-util/log-response logger-tag rid "response" body)
96-
(response-body->result body))))
97-
(catch Exception e
98-
(on-error {:exception e}))))))
96+
(response-body->result body)))))
97+
(catch Exception e
98+
(on-error {:exception e
99+
:message (format "Connection error: %s" (or (ex-message e) (.getName (class e))))})))))
99100

100101
(defn ^:private normalize-messages [messages supports-image?]
101102
(keep (fn [{:keys [role content] :as msg}]

src/eca/llm_providers/openai_chat.clj

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,14 @@
129129
"Content-Type" "application/json"}
130130
extra-headers))]
131131
(llm-util/log-request logger-tag rid url body headers)
132-
(let [{:keys [status body]} (http/post
133-
url
134-
{:headers headers
135-
:body (json/generate-string body)
136-
:throw-exceptions? false
137-
:http-client (client/merge-with-global-http-client http-client)
138-
:as (if on-stream :stream :json)})]
139-
(try
132+
(try
133+
(let [{:keys [status body]} (http/post
134+
url
135+
{:headers headers
136+
:body (json/generate-string body)
137+
:throw-exceptions? false
138+
:http-client (client/merge-with-global-http-client http-client)
139+
:as (if on-stream :stream :json)})]
140140
(if (not= 200 status)
141141
(let [body-str (if on-stream (slurp body) body)]
142142
(logger/warn logger-tag rid "Unexpected response status: %s body: %s" status body-str)
@@ -151,9 +151,10 @@
151151
(on-stream "stream-end" {}))
152152
(do
153153
(llm-util/log-response logger-tag rid "full-response" body)
154-
(response-body->result body on-tools-called-wrapper))))
155-
(catch Exception e
156-
(on-error {:exception e}))))))
154+
(response-body->result body on-tools-called-wrapper)))))
155+
(catch Exception e
156+
(on-error {:exception e
157+
:message (format "Connection error: %s" (or (ex-message e) (.getName (class e))))})))))
157158

158159
(defn ^:private transform-message
159160
"Transform a single ECA message to OpenAI format. Returns nil for unsupported roles.

0 commit comments

Comments
 (0)