Skip to content

fix(client): harden artifacts.get and follow Modal 303 long requests - #201

Open
dineshreddy91 wants to merge 1 commit into
mainfrom
fix/orion-artifacts-303-polling
Open

fix(client): harden artifacts.get and follow Modal 303 long requests#201
dineshreddy91 wants to merge 1 commit into
mainfrom
fix/orion-artifacts-303-polling

Conversation

@dineshreddy91

@dineshreddy91 dineshreddy91 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Strip optional file extensions from artifact IDs (img_abc123.jpgimg_abc123)
  • Stop hard-asserting a single Content-Type; accept image/* and application/octet-stream
  • Follow Modal 303 See Other on chat completions and poll Location until 200/201 with a body
  • Treat 204 / transient 5xx / connection holds as pending while polling

Test plan

  • pytest tests/test_artifacts.py (16 tests)
  • artifacts.get(object_id="img_xxxx.jpg", …) succeeds when API returns application/octet-stream
  • Slow agent.completions.create that returns 303 resolves via Location polling instead of raising

Made with Cursor


Open in Devin Review

Strip optional extensions from artifact IDs, stop asserting a single
image Content-Type, and poll Location after a 303 so slow Orion
completions do not fail in the OpenAI-compatible client.

Co-authored-by: Cursor <cursoragent@cursor.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements support for Modal's long-request polling flow (handling 303 See Other redirects) during chat completions, adds a dedicated helper module for polling, and refactors artifact retrieval to normalize object IDs and handle content types more robustly. The review feedback identifies several key issues: a runtime error from directly instantiating Pydantic v2's AnyHttpUrl, potential MissingSchema exceptions from relative redirect URLs, an infinite loop risk on empty 200/201 responses, Python 3.8 incompatibility with asyncio.to_thread, case-sensitive header lookups, and a duplicated file extension bug when caching URL artifacts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

return Image.open(io.BytesIO(response)).convert("RGB")
elif obj_type == "url":
# Get the filename including extension frm the URL by stripping any query parameters
url: AnyHttpUrl = AnyHttpUrl(response.decode("utf-8"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

In Pydantic v2, AnyHttpUrl is a type alias and cannot be instantiated directly like a class (e.g., AnyHttpUrl(...)). This will raise a TypeError at runtime. Use TypeAdapter to validate and instantiate it properly.

Suggested change
url: AnyHttpUrl = AnyHttpUrl(response.decode("utf-8"))
from pydantic import TypeAdapter
url = TypeAdapter(AnyHttpUrl).validate_python(response.decode("utf-8"))

Comment thread vlmrun/client/agent.py
Comment on lines +87 to +89
location = extract_location(_headers_from_exc(exc))
if not location:
raise exc

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Relative redirect URLs in the Location header will cause requests to raise a MissingSchema exception. To prevent this, resolve the Location URL against the original request or response URL.

    location = extract_location(_headers_from_exc(exc))
    if not location:
        raise exc

    response = getattr(exc, "response", None)
    if response is not None:
        request = getattr(response, "request", None)
        base_url = getattr(request, "url", None) or getattr(response, "url", None)
        if base_url:
            from urllib.parse import urljoin
            location = urljoin(str(base_url), location)

Comment on lines +124 to +125
response.raise_for_status()
time.sleep(poll_interval)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the server returns a 200 or 201 status code with an empty body, it is neither a result status nor a pending status. Without an explicit check, response.raise_for_status() will not raise an error, causing the client to loop indefinitely. Raise a ValueError to prevent this hang.

Suggested change
response.raise_for_status()
time.sleep(poll_interval)
if status in (200, 201):
raise ValueError(f"Received empty response body with status {status}")
response.raise_for_status()
time.sleep(poll_interval)

Comment thread vlmrun/client/agent.py
Comment on lines +141 to +146
return await asyncio.to_thread(
_follow_long_request_303,
exc,
api_key=api_key,
timeout=max(float(timeout or 0), _DEFAULT_LONG_REQUEST_TIMEOUT),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

asyncio.to_thread was introduced in Python 3.9. To maintain compatibility with Python 3.8, use loop.run_in_executor with functools.partial instead.

Suggested change
return await asyncio.to_thread(
_follow_long_request_303,
exc,
api_key=api_key,
timeout=max(float(timeout or 0), _DEFAULT_LONG_REQUEST_TIMEOUT),
)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None,
functools.partial(
_follow_long_request_303,
exc,
api_key=api_key,
timeout=max(float(timeout or 0), _DEFAULT_LONG_REQUEST_TIMEOUT),
),
)

Comment on lines +81 to +82
raw = headers.get("Content-Type") or headers.get("content-type") or ""
return raw.split(";", 1)[0].strip().lower()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since headers is converted to a standard dict in APIRequestor.request, it is no longer case-insensitive. A case-insensitive lookup using a generator expression is more robust to handle any casing of the Content-Type header.

Suggested change
raw = headers.get("Content-Type") or headers.get("content-type") or ""
return raw.split(";", 1)[0].strip().lower()
raw = next((v for k, v in headers.items() if k.lower() == "content-type"), "")
return raw.split(";", 1)[0].strip().lower()

Comment on lines +195 to +197
url_filename: str = path.name.split("?")[0]
ext: str = url_filename.split(".")[-1].lower()
tmp_path: Path = artifacts_dir / f"{url_filename}.{ext}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Extracting the extension from url_filename and then appending it again results in a duplicated file extension (e.g., image.jpg.jpg). Since url_filename already contains the extension, you can use it directly.

Suggested change
url_filename: str = path.name.split("?")[0]
ext: str = url_filename.split(".")[-1].lower()
tmp_path: Path = artifacts_dir / f"{url_filename}.{ext}"
url_filename: str = path.name.split("?")[0]
tmp_path: Path = artifacts_dir / url_filename

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Open in Devin Review

Comment on lines +26 to +27
class LongRequestTimeoutError(TimeoutError):
"""Raised when polling a long-request Location URL times out."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Custom timeout error bypasses the project's exception hierarchy, so callers catching SDK errors will miss it

The new timeout error class inherits from Python's builtin TimeoutError (LongRequestTimeoutError(TimeoutError) at vlmrun/client/long_request.py:26) instead of the project's RequestTimeoutError or VLMRunError, so user code that catches the documented SDK exception types will not catch this error.

Impact: Users relying on except VLMRunError or except RequestTimeoutError will get an unhandled exception when a long-running agent request times out.

Exception hierarchy violation and missing suggestion field

The AGENTS.md rules state:

  • "Use the existing exception hierarchy in vlmrun/client/exceptions.py. Do not raise generic Exception or ValueError for API-related errors."
  • "Every custom exception should include a helpful suggestion field guiding the user toward resolution."
  • "Preserve the pattern: VLMRunErrorAPIError / ClientError → specific error types."

The project already has RequestTimeoutError at vlmrun/client/exceptions.py:154 which inherits from APIErrorVLMRunError and includes a suggestion field. LongRequestTimeoutError should either inherit from RequestTimeoutError or at minimum from VLMRunError/ClientError, and should include a suggestion field.

The error is raised at vlmrun/client/long_request.py:87 and propagates through _follow_long_request_303 (vlmrun/client/agent.py:95-100) to the user.

Prompt for agents
The LongRequestTimeoutError class at vlmrun/client/long_request.py:26 inherits from Python's builtin TimeoutError. Per the project's AGENTS.md rules, it should use the existing exception hierarchy from vlmrun/client/exceptions.py. It should either inherit from RequestTimeoutError (which already exists for timeout scenarios) or at minimum from ClientError/VLMRunError, and should include a suggestion field. You'll need to import the appropriate base class from vlmrun.client.exceptions and add a suggestion field like 'Increase the timeout or check if the agent job is still running'.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

def poll_location(
location: str,
*,
headers: Optional[Mapping[str, str]] = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New file uses legacy Optional type syntax instead of the mandated pipe-union style

Type annotations use Optional[X] (Optional[Mapping[str, str]] at vlmrun/client/long_request.py:54) instead of the required X | None syntax in a brand-new file that already has from __future__ import annotations.

Impact: Inconsistency with the codebase style rules; no runtime effect but violates mandatory coding standards.

Multiple Optional usages in the new long_request.py file

AGENTS.md states: "Use X | None instead of Optional[X] for type hints (PEP 604)."

Violations in the new file vlmrun/client/long_request.py:

  • Line 54: headers: Optional[Mapping[str, str]] = None → should be Mapping[str, str] | None = None
  • Line 58: session: Optional[requests.Session] = None → should be requests.Session | None = None
  • Line 131: -> Optional[str]: → should be -> str | None:

The file already has from __future__ import annotations at line 14, so the | syntax works without issue.

Suggested change
headers: Optional[Mapping[str, str]] = None,
headers: Mapping[str, str] | None = None,
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant