perf(server): unblock event loop on rate-limit waits and markitdown

Two complementary changes to mitigate intermittent TLS handshake
timeouts and "notifications/cancelled: Bad Request" seen against the
single-worker uvicorn deployment.

1. bedesten rate-limiter back-pressure
   - Add optional ``max_wait`` to ``_TokenBucket.acquire``: if the next
     wait would exceed it, raise ``BedestenRateLimited`` immediately
     instead of sleeping. After a server-side 429 the bucket pauses for
     up to 30s; previously a queued request sat in ``asyncio.sleep``
     for that whole window, holding the worker slot and pushing the
     MCP client past its cancellation timeout.
   - ``search_bedesten_unified`` / ``get_bedesten_document_markdown``
     catch ``BedestenRateLimited`` and reuse the existing structured
     429-style response, so callers get a fast, clean retry signal.
   - Tunable via ``BEDESTEN_RATE_MAX_WAIT_S`` (default 8.0s).

2. Offload sync markitdown conversions to a thread
   - Every ``markitdown.convert*`` call site is now wrapped in
     ``asyncio.to_thread(...)`` across 14 modules (bedesten, yargitay,
     danistay, anayasa norm + bireysel, uyusmazlik, emsal, rekabet,
     gib, kvkk, sayistay, bddk, sigorta_tahkim, kik_v2). PDF / large
     HTML parsing was stalling the event loop for seconds, which on a
     single-worker deployment delayed every other in-flight request
     and queued new TLS handshakes until they timed out.

Verified locally:
- ``ast.parse`` + ``importlib.import_module`` on all 15 modified files
- ``mcp_server_main.create_app()`` constructs successfully
- New ``_TokenBucket.acquire(max_wait=...)`` smoke-tested across 6
  paths: capacity-available, no-arg backward compat, max_wait raise,
  max_wait wait+succeed, ``penalize_until`` + max_wait fast-raise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saidsurucu
2026-05-11 14:31:23 +03:00
co-authored by Claude Opus 4.7
parent 26aa3dacc6
commit 96a5a538b2
15 changed files with 120 additions and 26 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
# anayasa_mcp_module/bireysel_client.py # anayasa_mcp_module/bireysel_client.py
# This client is for Bireysel Başvuru: https://kararlarbilgibankasi.anayasa.gov.tr # This client is for Bireysel Başvuru: https://kararlarbilgibankasi.anayasa.gov.tr
import asyncio
import httpx import httpx
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
@@ -302,7 +303,7 @@ class AnayasaBireyselBasvuruApiClient:
elif "Karar Tarihi" in key and not karar_tarihi_from_page: karar_tarihi_from_page = value elif "Karar Tarihi" in key and not karar_tarihi_from_page: karar_tarihi_from_page = value
elif "Resmi Gazete Tarih / Sayı" in key: resmi_gazete_info_from_page = value elif "Resmi Gazete Tarih / Sayı" in key: resmi_gazete_info_from_page = value
full_markdown_content = self._convert_html_to_markdown_bireysel(html_content_from_api) full_markdown_content = await asyncio.to_thread(self._convert_html_to_markdown_bireysel, html_content_from_api)
if not full_markdown_content: if not full_markdown_content:
return AnayasaBireyselBasvuruDocumentMarkdown( return AnayasaBireyselBasvuruDocumentMarkdown(
+2 -1
View File
@@ -1,6 +1,7 @@
# anayasa_mcp_module/client.py # anayasa_mcp_module/client.py
# This client is for Norm Denetimi: https://normkararlarbilgibankasi.anayasa.gov.tr # This client is for Norm Denetimi: https://normkararlarbilgibankasi.anayasa.gov.tr
import asyncio
import httpx import httpx
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
@@ -309,7 +310,7 @@ class AnayasaMahkemesiApiClient:
official_gazette_from_page = rg_text_content.replace("Resmî Gazete tarih ve sayısı:", "").replace("Resmi Gazete tarih/sayı:", "").strip() official_gazette_from_page = rg_text_content.replace("Resmî Gazete tarih ve sayısı:", "").replace("Resmi Gazete tarih/sayı:", "").strip()
full_markdown_content = self._convert_html_to_markdown_norm_denetimi(html_content_from_api) full_markdown_content = await asyncio.to_thread(self._convert_html_to_markdown_norm_denetimi, html_content_from_api)
if not full_markdown_content: if not full_markdown_content:
return AnayasaDocumentMarkdown( return AnayasaDocumentMarkdown(
+10 -4
View File
@@ -1,5 +1,6 @@
# bddk_mcp_module/client.py # bddk_mcp_module/client.py
import asyncio
import httpx import httpx
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
import logging import logging
@@ -210,14 +211,19 @@ class BddkApiClient:
# Convert to Markdown based on content type # Convert to Markdown based on content type
if "pdf" in content_type: if "pdf" in content_type:
# Handle PDF documents # Handle PDF documents. markitdown is sync; offload to thread
# so PDF parsing doesn't block the event-loop / other requests.
pdf_stream = io.BytesIO(response.content) pdf_stream = io.BytesIO(response.content)
result = self.markitdown.convert_stream(pdf_stream, file_extension=".pdf") result = await asyncio.to_thread(
self.markitdown.convert_stream, pdf_stream, file_extension=".pdf"
)
markdown_content = result.text_content markdown_content = result.text_content
else: else:
# Handle HTML documents # Handle HTML documents (sync conversion offloaded to thread)
html_stream = io.BytesIO(response.content) html_stream = io.BytesIO(response.content)
result = self.markitdown.convert_stream(html_stream, file_extension=".html") result = await asyncio.to_thread(
self.markitdown.convert_stream, html_stream, file_extension=".html"
)
markdown_content = result.text_content markdown_content = result.text_content
# Clean up the markdown content # Clean up the markdown content
+39 -6
View File
@@ -21,6 +21,19 @@ from .enums import get_full_birim_adi
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BedestenRateLimited(Exception):
"""Raised when the local rate-limit bucket would block longer than allowed.
Carries the suggested retry-after (seconds) so callers can surface a
structured 429-style response to the MCP client instead of silently
blocking the event-loop slot for the full bucket-pause window.
"""
def __init__(self, retry_after: float) -> None:
self.retry_after = retry_after
super().__init__(f"local bucket would block {retry_after:.1f}s")
class _TokenBucket: class _TokenBucket:
"""Asyncio token bucket with explicit back-pressure. """Asyncio token bucket with explicit back-pressure.
@@ -40,7 +53,12 @@ class _TokenBucket:
self._not_before = 0.0 self._not_before = 0.0
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
async def acquire(self) -> None: async def acquire(self, max_wait: Optional[float] = None) -> None:
"""Acquire one token. If ``max_wait`` is set and the next wait would
exceed it, raise :class:`BedestenRateLimited` immediately instead of
sleeping — keeps a single rate-limited request from holding the
worker-slot for the full bucket-pause window (up to ~30s on 429)."""
deadline = (time.monotonic() + max_wait) if max_wait is not None else None
while True: while True:
async with self._lock: async with self._lock:
now = time.monotonic() now = time.monotonic()
@@ -56,6 +74,10 @@ class _TokenBucket:
self._tokens -= 1.0 self._tokens -= 1.0
return return
wait_s = (1.0 - self._tokens) / self.refill_per_s wait_s = (1.0 - self._tokens) / self.refill_per_s
if deadline is not None:
remaining = deadline - time.monotonic()
if wait_s > remaining:
raise BedestenRateLimited(retry_after=wait_s)
await asyncio.sleep(wait_s) await asyncio.sleep(wait_s)
def penalize_until(self, monotonic_deadline: float) -> None: def penalize_until(self, monotonic_deadline: float) -> None:
@@ -78,8 +100,11 @@ class BedestenApiClient:
# 3.5s spacing (no burst, ~14% safety margin). Override via env: # 3.5s spacing (no burst, ~14% safety margin). Override via env:
# BEDESTEN_RATE_CAPACITY (default 1) # BEDESTEN_RATE_CAPACITY (default 1)
# BEDESTEN_RATE_REFILL_S (default 3.5; seconds per token) # BEDESTEN_RATE_REFILL_S (default 3.5; seconds per token)
# BEDESTEN_RATE_MAX_WAIT_S (default 8.0; max seconds to wait in the
# local bucket before returning a structured 429 to the caller)
_DEFAULT_CAPACITY = int(os.getenv("BEDESTEN_RATE_CAPACITY", "1")) _DEFAULT_CAPACITY = int(os.getenv("BEDESTEN_RATE_CAPACITY", "1"))
_DEFAULT_REFILL_S = float(os.getenv("BEDESTEN_RATE_REFILL_S", "3.5")) _DEFAULT_REFILL_S = float(os.getenv("BEDESTEN_RATE_REFILL_S", "3.5"))
_DEFAULT_MAX_WAIT_S = float(os.getenv("BEDESTEN_RATE_MAX_WAIT_S", "8.0"))
def __init__(self, request_timeout: float = 60.0): def __init__(self, request_timeout: float = 60.0):
self.http_client = httpx.AsyncClient( self.http_client = httpx.AsyncClient(
@@ -137,7 +162,7 @@ class BedestenApiClient:
if not request_dict["data"]["birimAdi"]: # Remove if empty string if not request_dict["data"]["birimAdi"]: # Remove if empty string
del request_dict["data"]["birimAdi"] del request_dict["data"]["birimAdi"]
await self._bucket.acquire() await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
response = await self.http_client.post( response = await self.http_client.post(
self.SEARCH_ENDPOINT, self.SEARCH_ENDPOINT,
json=request_dict json=request_dict
@@ -171,7 +196,7 @@ class BedestenApiClient:
) )
# Get document # Get document
await self._bucket.acquire() await self._bucket.acquire(max_wait=self._DEFAULT_MAX_WAIT_S)
response = await self.http_client.post( response = await self.http_client.post(
self.DOCUMENT_ENDPOINT, self.DOCUMENT_ENDPOINT,
json=doc_request.model_dump() json=doc_request.model_dump()
@@ -202,12 +227,20 @@ class BedestenApiClient:
logger.info(f"BedestenApiClient: Document mime type: {mime_type}") logger.info(f"BedestenApiClient: Document mime type: {mime_type}")
# Convert to markdown based on mime type # Convert to markdown based on mime type. markitdown is sync and
# PDF parsing in particular can block the event-loop for seconds,
# which on a single-worker uvicorn deployment stalls every other
# in-flight MCP request and new TLS handshakes. Offload to a
# thread so the event-loop stays responsive.
if mime_type == "text/html": if mime_type == "text/html":
html_content = content_bytes.decode('utf-8') html_content = content_bytes.decode('utf-8')
markdown_content = self._convert_html_to_markdown(html_content) markdown_content = await asyncio.to_thread(
self._convert_html_to_markdown, html_content
)
elif mime_type == "application/pdf": elif mime_type == "application/pdf":
markdown_content = self._convert_pdf_to_markdown(content_bytes) markdown_content = await asyncio.to_thread(
self._convert_pdf_to_markdown, content_bytes
)
else: else:
logger.warning(f"Unsupported mime type: {mime_type}") logger.warning(f"Unsupported mime type: {mime_type}")
markdown_content = f"Unsupported content type: {mime_type}. Unable to convert to markdown." markdown_content = f"Unsupported content type: {mime_type}. Unable to convert to markdown."
+2 -1
View File
@@ -1,5 +1,6 @@
# danistay_mcp_module/client.py # danistay_mcp_module/client.py
import asyncio
import httpx import httpx
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
@@ -170,7 +171,7 @@ class DanistayApiClient:
source_url=source_url source_url=source_url
) )
markdown_content = self._convert_html_to_markdown_danistay(html_content_from_api) markdown_content = await asyncio.to_thread(self._convert_html_to_markdown_danistay, html_content_from_api)
return DanistayDocumentMarkdown( return DanistayDocumentMarkdown(
id=id, id=id,
+2 -1
View File
@@ -1,5 +1,6 @@
# emsal_mcp_module/client.py # emsal_mcp_module/client.py
import asyncio
import httpx import httpx
# from bs4 import BeautifulSoup # Uncomment if needed for advanced HTML pre-processing # from bs4 import BeautifulSoup # Uncomment if needed for advanced HTML pre-processing
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
@@ -153,7 +154,7 @@ class EmsalApiClient:
logger.warning(f"EmsalApiClient: Received empty or non-string HTML in 'data' field for Emsal ID {id}.") logger.warning(f"EmsalApiClient: Received empty or non-string HTML in 'data' field for Emsal ID {id}.")
return EmsalDocumentMarkdown(id=id, markdown_content=None, source_url=source_url) return EmsalDocumentMarkdown(id=id, markdown_content=None, source_url=source_url)
markdown_content = self._clean_html_and_convert_to_markdown_emsal(html_content_from_api) markdown_content = await asyncio.to_thread(self._clean_html_and_convert_to_markdown_emsal, html_content_from_api)
return EmsalDocumentMarkdown( return EmsalDocumentMarkdown(
id=id, id=id,
+2 -1
View File
@@ -1,5 +1,6 @@
# gib_mcp_module/client.py # gib_mcp_module/client.py
import asyncio
import httpx import httpx
import io import io
import logging import logging
@@ -302,7 +303,7 @@ class GibApiClient:
item = content[0] if isinstance(content[0], dict) else {} item = content[0] if isinstance(content[0], dict) else {}
description_html = item.get("description") or "" description_html = item.get("description") or ""
markdown_body = self._convert_html_to_markdown(description_html) or "" markdown_body = (await asyncio.to_thread(self._convert_html_to_markdown, description_html)) or ""
header_block = self._build_header_block(item) header_block = self._build_header_block(item)
if header_block and markdown_body: if header_block and markdown_body:
+4 -1
View File
@@ -1,5 +1,6 @@
# kik_mcp_module/client_v2.py # kik_mcp_module/client_v2.py
import asyncio
import httpx import httpx
import logging import logging
import uuid import uuid
@@ -439,7 +440,9 @@ class KikV2ApiClient:
html_bytes = html_content.encode('utf-8') html_bytes = html_content.encode('utf-8')
html_stream = BytesIO(html_bytes) html_stream = BytesIO(html_bytes)
result = md.convert_stream(html_stream, file_extension=".html") # markitdown is sync; offload to thread so HTML parsing doesn't
# block the event-loop / other in-flight MCP requests.
result = await asyncio.to_thread(md.convert_stream, html_stream, file_extension=".html")
markdown_content = result.text_content markdown_content = result.text_content
return KikV2DocumentMarkdown( return KikV2DocumentMarkdown(
+2 -1
View File
@@ -1,5 +1,6 @@
# kvkk_mcp_module/client.py # kvkk_mcp_module/client.py
import asyncio
import httpx import httpx
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
@@ -291,7 +292,7 @@ class KvkkApiClient:
# Convert HTML content to Markdown # Convert HTML content to Markdown
full_markdown_content = None full_markdown_content = None
if extracted_data["html_content"]: if extracted_data["html_content"]:
full_markdown_content = self._convert_html_to_markdown(extracted_data["html_content"]) full_markdown_content = await asyncio.to_thread(self._convert_html_to_markdown, extracted_data["html_content"])
if not full_markdown_content: if not full_markdown_content:
return KvkkDocumentMarkdown( return KvkkDocumentMarkdown(
+38 -1
View File
@@ -258,7 +258,7 @@ def create_app():
# --- Module Imports --- # --- Module Imports ---
from yargitay_mcp_module.client import YargitayOfficialApiClient from yargitay_mcp_module.client import YargitayOfficialApiClient
from bedesten_mcp_module.client import BedestenApiClient from bedesten_mcp_module.client import BedestenApiClient, BedestenRateLimited
from bedesten_mcp_module.models import ( from bedesten_mcp_module.models import (
BedestenSearchRequest, BedestenSearchData, BedestenSearchRequest, BedestenSearchData,
BedestenDocumentMarkdown, BedestenCourtTypeEnum BedestenDocumentMarkdown, BedestenCourtTypeEnum
@@ -1182,6 +1182,26 @@ For best results, use exact phrases with quotes for legal terms."""),
"page_size": pageSize, "page_size": pageSize,
"searched_courts": court_types "searched_courts": court_types
} }
except BedestenRateLimited as e:
retry_after = f"{e.retry_after:.1f}"
logger.warning(f"Bedesten local rate-limit bucket full for search; retry-after={retry_after}s")
return {
"decisions": [],
"total_records": 0,
"requested_page": pageNumber,
"page_size": pageSize,
"searched_courts": court_types,
"error": "rate_limit_exceeded",
"status_code": 429,
"retry_after": retry_after,
"message": (
"Bedesten istemci tarafı eşzamanlılık sınırına ulaşıldı "
"(yerel token-bucket dolu). Lütfen kısa bir süre bekleyip "
"aramayı tekrar deneyin. Alternatif olarak yargi-cli aracını "
"kullanabilirsiniz: https://github.com/saidsurucu/yargi-cli "
"(kullanım talimatları için repo README'sini okuyun)."
),
}
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
if e.response.status_code == 429: if e.response.status_code == 429:
retry_after = e.response.headers.get("Retry-After", "") retry_after = e.response.headers.get("Retry-After", "")
@@ -1227,6 +1247,23 @@ async def get_bedesten_document_markdown(
try: try:
return await bedesten_client_instance.get_document_as_markdown(documentId) return await bedesten_client_instance.get_document_as_markdown(documentId)
except BedestenRateLimited as e:
retry_after = f"{e.retry_after:.1f}"
logger.warning(f"Bedesten local rate-limit bucket full for document {documentId}; retry-after={retry_after}s")
message = (
"Bedesten istemci tarafı eşzamanlılık sınırına ulaşıldı "
"(yerel token-bucket dolu). Lütfen kısa bir süre bekleyip "
"belgeyi tekrar talep edin. Alternatif olarak yargi-cli aracını "
"kullanabilirsiniz: https://github.com/saidsurucu/yargi-cli "
"(kullanım talimatları için repo README'sini okuyun). "
f"Retry-After: {retry_after}"
)
return BedestenDocumentMarkdown(
documentId=documentId,
markdown_content=f"ERROR (rate_limit_exceeded, HTTP 429): {message}",
source_url=f"https://mevzuat.adalet.gov.tr/ictihat/{documentId}",
mime_type=None,
)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
if e.response.status_code == 429: if e.response.status_code == 429:
retry_after = e.response.headers.get("Retry-After", "") retry_after = e.response.headers.get("Retry-After", "")
+2 -1
View File
@@ -1,5 +1,6 @@
# rekabet_mcp_module/client.py # rekabet_mcp_module/client.py
import asyncio
import httpx import httpx
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from typing import List, Optional, Tuple, Dict, Any from typing import List, Optional, Tuple, Dict, Any
@@ -353,7 +354,7 @@ class RekabetKurumuApiClient:
total_pdf_pages = total_pdf_pages_from_extraction total_pdf_pages = total_pdf_pages_from_extraction
if single_page_pdf_bytes: if single_page_pdf_bytes:
markdown_for_requested_page = self._convert_pdf_bytes_to_markdown(single_page_pdf_bytes, str(pdf_url_to_report or full_landing_page_url)) markdown_for_requested_page = await asyncio.to_thread(self._convert_pdf_bytes_to_markdown, single_page_pdf_bytes, str(pdf_url_to_report or full_landing_page_url))
if not markdown_for_requested_page: if not markdown_for_requested_page:
error_message = (error_message or "") + f"; Could not convert page {page_number} of PDF to Markdown." error_message = (error_message or "") + f"; Could not convert page {page_number} of PDF to Markdown."
elif total_pdf_pages > 0 : elif total_pdf_pages > 0 :
+2 -1
View File
@@ -1,5 +1,6 @@
# sayistay_mcp_module/client.py # sayistay_mcp_module/client.py
import asyncio
import httpx import httpx
import re import re
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -657,7 +658,7 @@ class SayistayApiClient:
) )
# Convert HTML to Markdown using existing method # Convert HTML to Markdown using existing method
markdown_content = self._convert_html_to_markdown(html_content) markdown_content = await asyncio.to_thread(self._convert_html_to_markdown, html_content)
if markdown_content and "Error converting HTML content" not in markdown_content: if markdown_content and "Error converting HTML content" not in markdown_content:
logger.info(f"Successfully retrieved and converted document {decision_id} to Markdown") logger.info(f"Successfully retrieved and converted document {decision_id} to Markdown")
+6 -1
View File
@@ -1,5 +1,6 @@
# sigorta_tahkim_mcp_module/client.py # sigorta_tahkim_mcp_module/client.py
import asyncio
import httpx import httpx
from typing import Optional from typing import Optional
import logging import logging
@@ -202,7 +203,11 @@ class SigortaTahkimApiClient:
response.raise_for_status() response.raise_for_status()
pdf_stream = io.BytesIO(response.content) pdf_stream = io.BytesIO(response.content)
result = self.markitdown.convert_stream(pdf_stream, file_extension=".pdf") # markitdown is sync; offload to thread so PDF parsing doesn't block
# the event-loop / other in-flight MCP requests.
result = await asyncio.to_thread(
self.markitdown.convert_stream, pdf_stream, file_extension=".pdf"
)
return result.text_content.strip(), pdf_url return result.text_content.strip(), pdf_url
def _split_into_decisions(self, markdown_content: str) -> list[tuple[str, str]]: def _split_into_decisions(self, markdown_content: str) -> list[tuple[str, str]]:
+2 -1
View File
@@ -1,5 +1,6 @@
# uyusmazlik_mcp_module/client.py # uyusmazlik_mcp_module/client.py
import asyncio
import httpx import httpx
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from typing import Dict, Any, List, Optional, Union, Tuple from typing import Dict, Any, List, Optional, Union, Tuple
@@ -232,7 +233,7 @@ class UyusmazlikApiClient:
logger.warning(f"UyusmazlikApiClient: Received empty or non-string HTML from URL {document_url}.") logger.warning(f"UyusmazlikApiClient: Received empty or non-string HTML from URL {document_url}.")
return UyusmazlikDocumentMarkdown(source_url=document_url, markdown_content=None) return UyusmazlikDocumentMarkdown(source_url=document_url, markdown_content=None)
markdown_content = self._convert_html_to_markdown_uyusmazlik(html_content_from_api) markdown_content = await asyncio.to_thread(self._convert_html_to_markdown_uyusmazlik, html_content_from_api)
return UyusmazlikDocumentMarkdown(source_url=document_url, markdown_content=markdown_content) return UyusmazlikDocumentMarkdown(source_url=document_url, markdown_content=markdown_content)
except httpx.RequestError as e: except httpx.RequestError as e:
logger.error(f"UyusmazlikApiClient (httpx for docs): HTTP error fetching Uyuşmazlık document from {document_url}: {e}") logger.error(f"UyusmazlikApiClient (httpx for docs): HTTP error fetching Uyuşmazlık document from {document_url}: {e}")
+2 -1
View File
@@ -1,5 +1,6 @@
# yargitay_mcp_module/client.py # yargitay_mcp_module/client.py
import asyncio
import httpx import httpx
from bs4 import BeautifulSoup # Still needed for pre-processing HTML before markitdown from bs4 import BeautifulSoup # Still needed for pre-processing HTML before markitdown
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
@@ -159,7 +160,7 @@ class YargitayOfficialApiClient:
logger.error(f"YargitayOfficialApiClient: 'data' field in API response is not a string or not found (ID: {id}).") logger.error(f"YargitayOfficialApiClient: 'data' field in API response is not a string or not found (ID: {id}).")
raise ValueError("Expected HTML content not found in API response's 'data' field.") raise ValueError("Expected HTML content not found in API response's 'data' field.")
markdown_content = self._convert_html_to_markdown(html_content_from_api) markdown_content = await asyncio.to_thread(self._convert_html_to_markdown, html_content_from_api)
return YargitayDocumentMarkdown( return YargitayDocumentMarkdown(
id=id, id=id,