Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions imednet/core/http/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,22 @@ def send_fn() -> httpx.Response:
reraise=False,
)

response: Optional[httpx.Response] = None
with RequestMonitor(self.tracer, method, url) as monitor:
try:
response: httpx.Response = retryer(send_fn)
monitor.on_success(response)
response = retryer(send_fn)
if response is not None:
monitor.on_success(response)
except RetryError as e:
monitor.on_retry_error(e)
if e.last_attempt and not e.last_attempt.failed:
response = e.last_attempt.result()
monitor.on_success(response)
else:
monitor.on_retry_error(e)

if response is None:
# Should be unreachable as on_retry_error raises
raise RuntimeError("Request failed without response or exception")

return handle_response(response)

Expand Down Expand Up @@ -132,11 +142,21 @@ async def send_fn() -> httpx.Response:
reraise=False,
)

response: Optional[httpx.Response] = None
async with RequestMonitor(self.tracer, method, url) as monitor:
try:
response: httpx.Response = await retryer(send_fn)
monitor.on_success(response)
response = await retryer(send_fn)
if response is not None:
monitor.on_success(response)
except RetryError as e:
monitor.on_retry_error(e)
if e.last_attempt and not e.last_attempt.failed:
response = e.last_attempt.result()
monitor.on_success(response)
else:
monitor.on_retry_error(e)

if response is None:
# Should be unreachable as on_retry_error raises
raise RuntimeError("Request failed without response or exception")

return handle_response(response)
11 changes: 9 additions & 2 deletions imednet/core/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ def should_retry(self, state: RetryState) -> bool:


class DefaultRetryPolicy:
"""Retry only when a network :class:`httpx.RequestError` occurred."""
"""Retry on network errors, rate limits (429), and server errors (500-599)."""

def should_retry(self, state: RetryState) -> bool:
return isinstance(state.exception, httpx.RequestError)
if state.exception:
return isinstance(state.exception, httpx.RequestError)

response = state.result
if isinstance(response, httpx.Response):
return response.status_code == 429 or 500 <= response.status_code < 600

return False
32 changes: 23 additions & 9 deletions imednet/form_designer/builder.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import random
import string
from typing import List, Literal, Optional
from typing import Any, List, Literal, Optional, cast

from .models import (
CheckboxFieldProps,
Expand Down Expand Up @@ -147,19 +147,26 @@ def add_field(

if type == "text":
ctrl_props = TextFieldProps(
type="text", length=max_length or 100, columns=30, **common_kwargs
type="text",
length=max_length or 100,
columns=30,
**cast(Any, common_kwargs),
)
elif type == "memo":
ctrl_props = MemoFieldProps(
type="memo", length=max_length or 500, columns=40, rows=6, **common_kwargs
type="memo",
length=max_length or 500,
columns=40,
rows=6,
**cast(Any, common_kwargs),
)
elif type == "number":
ctrl_props = NumberFieldProps(
type="number",
length=max_length or 5,
columns=10,
real=1 if is_float else 0,
**common_kwargs,
**cast(Any, common_kwargs),
)
elif type == "radio":
field_choices = []
Expand All @@ -169,7 +176,10 @@ def add_field(
for t, c in choices
]
ctrl_props = RadioFieldProps(
type="radio", choices=field_choices, radio=1, **common_kwargs # Horizontal default
type="radio",
choices=field_choices,
radio=1,
**cast(Any, common_kwargs), # Horizontal default
)
elif type == "dropdown":
field_choices = []
Expand All @@ -178,15 +188,19 @@ def add_field(
Choice(text=t, code=c, choice_id=self._generate_new_fld_id())
for t, c in choices
]
ctrl_props = DropdownFieldProps(type="dropdown", choices=field_choices, **common_kwargs)
ctrl_props = DropdownFieldProps(
type="dropdown", choices=field_choices, **cast(Any, common_kwargs)
)
elif type == "checkbox":
field_choices = []
if choices:
field_choices = [
Choice(text=t, code=c, choice_id=self._generate_new_fld_id())
for t, c in choices
]
ctrl_props = CheckboxFieldProps(type="checkbox", choices=field_choices, **common_kwargs)
ctrl_props = CheckboxFieldProps(
type="checkbox", choices=field_choices, **cast(Any, common_kwargs)
)
elif type == "datetime":
ctrl_props = DateTimeFieldProps(
type="datetime",
Expand All @@ -195,14 +209,14 @@ def add_field(
allow_no_day=0,
allow_no_month=0,
allow_no_year=0,
**common_kwargs,
**cast(Any, common_kwargs),
)
elif type == "upload":
ctrl_props = FileUploadProps(
type="upload",
mfs=1,
max_files=10, # default to something reasonable if missing
**common_kwargs,
**cast(Any, common_kwargs),
)
else:
# Fallback (should not happen due to type hint)
Expand Down
4 changes: 2 additions & 2 deletions imednet/models/json_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,6 @@ def _normalise(cls, v: Any, info: Any) -> Any: # noqa: D401

# Bolt Optimization: Avoid function call overhead in hot path
try:
return _NORMALIZERS[cls][info.field_name](v)
return _NORMALIZERS[cls][info.field_name](v) # type: ignore[index]
except KeyError:
return _get_normalizer(cls, info.field_name)(v)
return _get_normalizer(cls, info.field_name)(v) # type: ignore[arg-type]
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ ignore_path = ["docs/_build"]
python_version = "3.10"
strict = false
ignore_missing_imports = true
plugins = ["pydantic.mypy"]
plugins = []
# ────────────────────────────────────────────────────────────────
# Ignore all files under examples/
exclude = '^examples/.*$'
20 changes: 18 additions & 2 deletions tests/core/test_retry_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,25 @@ def test_default_policy_request_error():
assert not policy.should_retry(RetryState(1))


def test_default_policy_non_retryable_response_and_exception():
def test_default_policy_retry_behavior():
policy = DefaultRetryPolicy()
assert not policy.should_retry(RetryState(1, result=httpx.Response(500)))

# Retryable responses (Server Errors & Rate Limits)
assert policy.should_retry(RetryState(1, result=httpx.Response(500)))
assert policy.should_retry(RetryState(1, result=httpx.Response(502)))
assert policy.should_retry(RetryState(1, result=httpx.Response(503)))
assert policy.should_retry(RetryState(1, result=httpx.Response(504)))
assert policy.should_retry(RetryState(1, result=httpx.Response(429)))

# Non-retryable responses (Client Errors & Success)
assert not policy.should_retry(RetryState(1, result=httpx.Response(400)))
assert not policy.should_retry(RetryState(1, result=httpx.Response(401)))
assert not policy.should_retry(RetryState(1, result=httpx.Response(403)))
assert not policy.should_retry(RetryState(1, result=httpx.Response(404)))
assert not policy.should_retry(RetryState(1, result=httpx.Response(200)))
assert not policy.should_retry(RetryState(1, result=httpx.Response(201)))

# Non-retryable exceptions
assert not policy.should_retry(RetryState(1, exception=Exception("boom")))
assert not policy.should_retry(RetryState(1, result=httpx.Response(200), exception=None))

Expand Down
24 changes: 12 additions & 12 deletions tests/unit/test_sdk_retry_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,33 +80,33 @@ def test_default_retry_policy_retries_connection_error(respx_mock_external) -> N
assert route.call_count == 3


def test_default_retry_policy_no_retry_on_500(respx_mock_external) -> None:
"""Verify that server errors (500) do NOT trigger retries."""
def test_default_retry_policy_retries_on_500(respx_mock_external) -> None:
"""Verify that server errors (500) trigger retries."""
sdk = ImednetSDK(api_key="k", security_key="s", base_url="https://example.com", retries=3)
route = respx_mock_external.get("/test").mock(return_value=httpx.Response(500))

with pytest.raises(exceptions.ServerError):
sdk._client.get("/test")

# Should call only once
assert route.call_count == 1
# Should attempt 3 times
assert route.call_count == 3


def test_default_retry_policy_no_retry_on_429(respx_mock_external) -> None:
"""Verify that rate limit errors (429) do NOT trigger retries."""
def test_default_retry_policy_retries_on_429(respx_mock_external) -> None:
"""Verify that rate limit errors (429) trigger retries."""
sdk = ImednetSDK(api_key="k", security_key="s", base_url="https://example.com", retries=3)
route = respx_mock_external.get("/test").mock(return_value=httpx.Response(429))

with pytest.raises(exceptions.RateLimitError):
sdk._client.get("/test")

# Should call only once
assert route.call_count == 1
# Should attempt 3 times
assert route.call_count == 3


@pytest.mark.asyncio
async def test_default_retry_policy_async_no_retry_on_500(respx_mock_external) -> None:
"""Verify that server errors (500) do NOT trigger retries in async client."""
async def test_default_retry_policy_async_retries_on_500(respx_mock_external) -> None:
"""Verify that server errors (500) trigger retries in async client."""
sdk = ImednetSDK(
api_key="k",
security_key="s",
Expand All @@ -121,5 +121,5 @@ async def test_default_retry_policy_async_no_retry_on_500(respx_mock_external) -
with pytest.raises(exceptions.ServerError):
await sdk._async_client.get("/test")

# Should call only once
assert route.call_count == 1
# Should attempt 3 times
assert route.call_count == 3