Files
Alexey MilovidovandClaude Opus 4.8 dfbc7c3249 AI functions: classify provider HTTP errors by status like the url table function
The previous version routed all provider failures through
`FunctionBaseAI::isRetriableProviderError`, but `OpenAIProvider` and
`AnthropicProvider` collapsed every non-2xx response into a plain `Exception`
with `RECEIVED_ERROR_FROM_REMOTE_IO_SERVER`, discarding the HTTP status. As a
result the classifier retried *all* provider HTTP errors when
`ai_function_max_retries` was set — including deterministic client errors
(400, 401, 403, 404, 405, 501) that the `url` table function never retries.
This contradicted the helper comment and PR description.

Preserve the HTTP status by introducing `AIProviderHTTPException`, a provider
exception that carries the status while keeping the existing
`RECEIVED_ERROR_FROM_REMOTE_IO_SERVER` code and the formatted provider message
(so error text and `ai_function_throw_on_error` behavior are unchanged). Both
providers now throw it.

Share the `url` retry policy itself: the predicate that was local to
`ReadWriteBufferFromHTTP.cpp` is exposed as `isRetriableHTTPError` in
`HTTPCommon`, and `isRetriableProviderError` classifies `AIProviderHTTPException`
through it. Deterministic statuses are surfaced immediately; transient/server
errors (5xx, rate limiting) and network failures are retried.

Also add the `Ok` comment to the `catch (...)` classifier branch to satisfy the
`catch_all` style check.

Tests: add `/v1/bad_request` (HTTP 400) to the mock server and assert a
deterministic status is not retried (`AIAPICalls` stays at 1) with
`ai_function_max_retries > 0`, that it surfaces under
`ai_function_throw_on_error = 1`, and a counterpart asserting HTTP 500 is
retried.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-19 23:52:08 +00:00

254 lines
9.6 KiB
Python

"""
Mock OpenAI-compatible HTTP server for AI function integration tests.
Endpoints:
GET /health — readiness probe, returns "OK"
GET /last-request — returns JSON `{"path": ..., "body": ..., "headers": ...}`
of the most recent POST received, so tests can assert on request contents (e.g. that
`aiTranslate`'s `instructions` argument is forwarded in the prompt, or that the
`Authorization` header is omitted when the named collection has no `api_key`).
Header names are lower-cased for case-insensitive lookup.
GET /set-flaky?count=N — arm the flaky endpoints below to fail their next N requests
with a simulated transient network error (used to exercise retries). `count=0` disarms.
POST /v1/chat/flaky — like `/v1/chat/completions`, but drops the connection without
a response for the first N requests after `/set-flaky?count=N`, then succeeds.
POST /v1/embeddings_flaky — like `/v1/embeddings`, but flaky in the same way as above.
POST /v1/chat/completions — returns response based on request content:
- If response_format with json_schema is present, returns JSON matching the schema
with values derived from the user message.
- Otherwise echoes the user message as plain text.
Fixed tokens: 10 input, 5 output.
POST /v1/embeddings — returns one deterministic embedding per input.
Honors `dimensions` if provided, otherwise returns DEFAULT_EMBED_DIM floats.
`prompt_tokens` = sum of input character lengths.
POST /v1/embeddings_dup_index — like `/v1/embeddings` but reuses `index` 0 for every
element, exercising the duplicate-index rejection path.
POST /v1/embeddings_wrong_count — returns one fewer entry than requested, exercising the
cardinality mismatch path.
POST /v1/error — always returns HTTP 500, a transient/server-side error that
the url table function (and so the AI functions) retries.
POST /v1/bad_request — always returns HTTP 400, a deterministic client error that
the url table function never retries, used to assert AI functions do not retry it either.
POST /v1/embeddings_error — always returns HTTP 500 (used for embedding errors)
"""
import http.server
import json
from urllib.parse import urlparse, parse_qs
MOCK_PORT = 18123
DEFAULT_EMBED_DIM = 4
# Single-threaded `HTTPServer` handles one request at a time, so a plain dict is safe.
LAST_REQUEST = {"path": None, "body": None, "headers": {}}
# Number of upcoming requests to the flaky endpoints (`/v1/chat/flaky`, `/v1/embeddings_flaky`)
# that should fail with a simulated transient network error before they start succeeding.
# Set via `GET /set-flaky?count=N`. Used to exercise the network-error retry path.
FLAKY = {"fails_remaining": 0}
def extract_user_message(body):
data = json.loads(body)
messages = data.get("messages", [])
for msg in reversed(messages):
if msg.get("role") == "user":
return msg.get("content", "")
return ""
def extract_response_format(body):
"""Extract the json_schema from response_format if present."""
data = json.loads(body)
rf = data.get("response_format")
if not rf or rf.get("type") != "json_schema":
return None
return rf.get("json_schema", {})
def build_structured_response(json_schema, user_message):
"""Build a JSON response matching the schema, using the user message as values."""
schema = json_schema.get("schema", {})
properties = schema.get("properties", {})
result = {}
for key, prop in properties.items():
if "enum" in prop:
# For classification: return the first enum value
result[key] = prop["enum"][0]
else:
result[key] = user_message
return json.dumps(result)
def make_success_response(content, prompt_tokens=10, completion_tokens=5):
return {
"choices": [
{
"message": {"content": content},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
},
}
def make_error_response(message, error_type="server_error"):
return {"error": {"message": message, "type": error_type}}
def make_embedding_vector(text, dim):
"""Return a deterministic float vector for `text` of length `dim`.
Values depend on text content so different inputs produce different vectors.
An empty `text` is supported (the function caller filters those out, but the
server should not crash if one slips through).
"""
if not text:
return [0.0] * dim
return [round(((ord(text[i % len(text)]) * (i + 1)) % 1000) / 1000.0, 3) for i in range(dim)]
def make_embeddings_response(body, *, duplicate_index=False, drop_last=False):
data = json.loads(body)
inputs = data.get("input", [])
if isinstance(inputs, str):
inputs = [inputs]
dim = int(data.get("dimensions") or 0) or DEFAULT_EMBED_DIM
items = []
for i, text in enumerate(inputs):
items.append({
"object": "embedding",
"index": 0 if duplicate_index else i,
"embedding": make_embedding_vector(text, dim),
})
if drop_last and items:
items.pop()
return {
"object": "list",
"data": items,
"model": data.get("model", "test-embed-model"),
"usage": {
"prompt_tokens": sum(len(t) for t in inputs),
"total_tokens": sum(len(t) for t in inputs),
},
}
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/health":
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"OK")
return
if parsed.path == "/last-request":
self._send_json(200, LAST_REQUEST)
return
if parsed.path == "/set-flaky":
qs = parse_qs(parsed.query)
FLAKY["fails_remaining"] = int(qs.get("count", ["0"])[0])
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"OK")
return
self.send_response(404)
self.end_headers()
def do_POST(self):
parsed = urlparse(self.path)
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8") if content_length else ""
LAST_REQUEST["path"] = parsed.path
LAST_REQUEST["body"] = body
LAST_REQUEST["headers"] = {k.lower(): v for k, v in self.headers.items()}
if parsed.path in ("/v1/chat/flaky", "/v1/embeddings_flaky"):
if FLAKY["fails_remaining"] > 0:
FLAKY["fails_remaining"] -= 1
# Simulate a transient network failure: close the connection without sending any
# response, so the client sees EOF — a Poco network exception — rather than an HTTP
# error status. This exercises the network-error retry path, distinct from the HTTP
# 500 path (`/v1/error`).
self.close_connection = True
return
if parsed.path == "/v1/chat/flaky":
self._send_json(200, make_success_response(extract_user_message(body)))
else:
self._send_json(200, make_embeddings_response(body))
return
if parsed.path == "/v1/chat/completions":
user_msg = extract_user_message(body)
json_schema = extract_response_format(body)
if json_schema:
content = build_structured_response(json_schema, user_msg)
else:
content = user_msg
self._send_json(200, make_success_response(content))
return
if parsed.path == "/v1/error":
self._send_json(500, make_error_response("permanent failure"))
return
if parsed.path == "/v1/bad_request":
# A deterministic client error (e.g. malformed request / bad API key). The url table
# function never retries 400, so neither should the AI functions.
self._send_json(400, make_error_response("invalid request", error_type="invalid_request_error"))
return
if parsed.path == "/v1/embeddings":
self._send_json(200, make_embeddings_response(body))
return
if parsed.path == "/v1/embeddings_dup_index":
self._send_json(200, make_embeddings_response(body, duplicate_index=True))
return
if parsed.path == "/v1/embeddings_wrong_count":
self._send_json(200, make_embeddings_response(body, drop_last=True))
return
if parsed.path == "/v1/embeddings_error":
self._send_json(500, make_error_response("embedding failure"))
return
self.send_response(404)
self.end_headers()
def _send_json(self, status, obj):
body = json.dumps(obj).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass # suppress request logs
if __name__ == "__main__":
server = http.server.HTTPServer(("0.0.0.0", MOCK_PORT), Handler)
try:
server.serve_forever()
finally:
server.server_close()