Skip to content

Commit 564d24c

Browse files
authored
fix(adapters): add a default timeout to Image.from_url / Audio.from_url (#10149)
requests.get without a timeout waits forever, so a slow or unresponsive host hangs the caller indefinitely. Adds timeout: float | None = 30.0 to both explicit download factories and the internal image helper (which also serves the deprecated Image(url, download=True) shim). Pass timeout=None for the old behavior. Remaining half of #9993; the implicit-fetch half was resolved by #10111.
1 parent a1fd926 commit 564d24c

3 files changed

Lines changed: 42 additions & 6 deletions

File tree

‎dspy/adapters/types/audio.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def validate_input(cls, values: Any) -> Any:
8080
return encode_audio(values)
8181

8282
@classmethod
83-
def from_url(cls, url: str, verify: bool = True) -> "Audio":
83+
def from_url(cls, url: str, verify: bool = True, timeout: float | None = 30.0) -> "Audio":
8484
"""
8585
Download an audio file from URL and encode it as base64.
8686
@@ -93,11 +93,14 @@ def from_url(cls, url: str, verify: bool = True) -> "Audio":
9393
Args:
9494
url: The URL of the audio to download.
9595
verify: Whether to verify SSL certificates. Set to False for self-signed certs.
96+
timeout: Seconds to wait for the server before raising
97+
``requests.exceptions.Timeout``. ``None`` disables the timeout and can
98+
hang indefinitely on an unresponsive host.
9699
"""
97100
parsed_url = urlparse(url)
98101
if parsed_url.scheme not in ("http", "https") or not parsed_url.netloc:
99102
raise ValueError(f"Audio.from_url requires an HTTP(S) URL, received: {url}")
100-
response = requests.get(url, verify=verify)
103+
response = requests.get(url, verify=verify, timeout=timeout)
101104
response.raise_for_status()
102105
mime_type = response.headers.get("Content-Type", "audio/wav")
103106
if not mime_type.startswith("audio/"):

‎dspy/adapters/types/image.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def format(self) -> list[dict[str, Any]] | str:
113113
return [{"type": "image_url", "image_url": {"url": image_url}}]
114114

115115
@classmethod
116-
def from_url(cls, url: str, verify: bool = True) -> "Image":
116+
def from_url(cls, url: str, verify: bool = True, timeout: float | None = 30.0) -> "Image":
117117
"""Download an HTTP(S) resource and encode it as a data URI.
118118
119119
Security: this performs an explicit, caller-initiated fetch and applies no
@@ -124,7 +124,7 @@ def from_url(cls, url: str, verify: bool = True) -> "Image":
124124
"""
125125
if not _is_http_url(url):
126126
raise ValueError(f"Image.from_url requires an HTTP(S) URL, received: {url}")
127-
return cls(_encode_image_from_url(url, verify=verify))
127+
return cls(_encode_image_from_url(url, verify=verify, timeout=timeout))
128128

129129
@classmethod
130130
def from_path(cls, file_path: str) -> "Image":
@@ -236,14 +236,16 @@ def _encode_image_from_file(file_path: str) -> str:
236236
return f"data:{mime_type};base64,{encoded_data}"
237237

238238

239-
def _encode_image_from_url(image_url: str, verify: bool = True) -> str:
239+
def _encode_image_from_url(image_url: str, verify: bool = True, timeout: float | None = 30.0) -> str:
240240
"""Encode a file from a URL to a base64 data URI.
241241
242242
Args:
243243
image_url: The URL of the image to download.
244244
verify: Whether to verify SSL certificates. Set to False for self-signed certs.
245+
timeout: Seconds to wait for the server before raising ``requests.exceptions.Timeout``.
246+
``None`` disables the timeout and can hang indefinitely on an unresponsive host.
245247
"""
246-
response = requests.get(image_url, verify=verify)
248+
response = requests.get(image_url, verify=verify, timeout=timeout)
247249
response.raise_for_status()
248250
content_type = response.headers.get("Content-Type", "")
249251

‎tests/adapters/test_resource_loading.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,37 @@ def raise_for_status(self):
140140
assert base64.b64decode(encoded) == b"remote bytes"
141141

142142

143+
@pytest.mark.parametrize(
144+
("factory", "module", "mime_type"),
145+
[(dspy.Image.from_url, "image", "image/png"), (dspy.Audio.from_url, "audio", "audio/wav")],
146+
)
147+
def test_from_url_applies_a_timeout_by_default(monkeypatch, factory, module, mime_type):
148+
seen = {}
149+
150+
class Response:
151+
def __init__(self):
152+
self.content = b"remote bytes"
153+
self.headers = {"Content-Type": mime_type}
154+
155+
def raise_for_status(self):
156+
return None
157+
158+
def record_request(url, **kwargs):
159+
seen.update(kwargs)
160+
return Response()
161+
162+
monkeypatch.setattr(f"dspy.adapters.types.{module}.requests.get", record_request)
163+
164+
factory("https://example.com/resource")
165+
assert seen.get("timeout") == 30.0
166+
167+
factory("https://example.com/resource", timeout=5.0)
168+
assert seen.get("timeout") == 5.0
169+
170+
factory("https://example.com/resource", timeout=None)
171+
assert seen.get("timeout") is None
172+
173+
143174
def test_in_memory_resource_construction():
144175
image = dspy.Image(
145176
base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")

0 commit comments

Comments
 (0)