Skip to content
4 changes: 2 additions & 2 deletions runpod/endpoint/helpers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
""" Helper functions for the Runpod Endpoint API. """

FINAL_STATES = ["COMPLETED", "FAILED", "TIMED_OUT"]
FINAL_STATES = ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELLED"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good catch aligning FINAL_STATES with is_completed. This is broader than a cleanup, though: FINAL_STATES is consumed directly in runner.py stream() (and the asyncio runner). Before this change, streaming a CANCELLED job never hit the breakCANCELLED not in FINAL_STATES stayed true, so the loop spun forever. Adding CANCELLED fixes that latent infinite loop. Please add a test that stream() terminates on CANCELLED (or at minimum assert "CANCELLED" in FINAL_STATES) so the fix is locked in.


# Exception Messages
UNAUTHORIZED_MSG = "401 Unauthorized | Make sure Runpod API key is set and valid."
Expand All @@ -14,4 +14,4 @@

def is_completed(status: str) -> bool:
"""Returns true if status is one of the possible final states for a serverless request."""
return status in ["COMPLETED", "FAILED", "TIMED_OUT", "CANCELLED"]
return status in FINAL_STATES
16 changes: 14 additions & 2 deletions runpod/endpoint/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,20 @@ def __init__(self, api_key: Optional[str] = None):
raise RuntimeError(API_KEY_NOT_SET_MSG)

self.rp_session = requests.Session()
retries = Retry(total=5, backoff_factor=1, status_forcelist=[408, 429])
self.rp_session.mount("http://", HTTPAdapter(max_retries=retries))
# Only auto-retry idempotent methods. POST paths here (/run, /runsync)
# are non-idempotent and have no idempotency key, so a retry after a
# lost response or transient 5xx could create duplicate jobs.
retries = Retry(
total=5,
backoff_factor=1,
status_forcelist=[408, 429, 500, 502, 503, 504],
allowed_methods=frozenset(["GET"]),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two things on this retry policy:

  1. Behavior change: POSTs are no longer retried on 429. Previously all methods retried on 408/429; now /run, /runsync, /cancel retry on nothing. That's the right call for duplicate-job safety given there's no idempotency key, but a 429 is a rejected request that is safe to retry — worth documenting the tradeoff rather than leaving it implicit in the allowed_methods blanket.
  2. No test coverage: this is the headline "don't create duplicate jobs" fix and nothing guards it. A future refactor could silently reintroduce POST retries. Please add a regression test asserting the mounted adapter's Retry.allowed_methods == {"GET"} and that both http:// and https:// are mounted.

)
adapter = HTTPAdapter(max_retries=retries)
# The production API is served over https; mount on both schemes so the
# retry/backoff policy actually applies to real traffic.
self.rp_session.mount("https://", adapter)
self.rp_session.mount("http://", adapter)

self.headers = {
"Content-Type": "application/json",
Expand Down
20 changes: 12 additions & 8 deletions runpod/serverless/modules/rp_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
log = RunPodLogger()


async def _transmit(client_session: ClientSession, url, job_data):
async def _transmit(client_session: ClientSession, url, job_data, request_id=None):
"""
Wrapper for transmitting results via POST.
"""
Expand All @@ -35,12 +35,18 @@ async def _transmit(client_session: ClientSession, url, job_data):
client_session=client_session, retry_options=retry_options
)

headers = {
"charset": "utf-8",
"Content-Type": "application/x-www-form-urlencoded",
}
# Pass the request id per-request rather than mutating the shared session's
# headers, which would race across concurrently handled jobs.
if request_id is not None:
headers["X-Request-ID"] = request_id

kwargs = {
"data": job_data,
"headers": {
"charset": "utf-8",
"Content-Type": "application/x-www-form-urlencoded",
},
"headers": headers,
"raise_for_status": True,
}

Expand All @@ -55,14 +61,12 @@ async def _handle_result(
A helper function to handle the result, either for sending or streaming.
"""
try:
session.headers["X-Request-ID"] = job["id"]

serialized_job_data = json.dumps(job_data, ensure_ascii=False)

is_stream = "true" if is_stream else "false"
url = url_template.replace("$ID", job["id"]) + f"&isStream={is_stream}"

await _transmit(session, url, serialized_job_data)
await _transmit(session, url, serialized_job_data, request_id=job["id"])
log.debug(f"{log_message}", job["id"])

except ClientError as err:
Expand Down
2 changes: 2 additions & 0 deletions tests/test_serverless/test_modules/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ async def test_send_result(self):
headers={
"charset": "utf-8",
"Content-Type": "application/x-www-form-urlencoded",
"X-Request-ID": self.job["id"],
},
raise_for_status=True,
)
Expand Down Expand Up @@ -159,6 +160,7 @@ async def test_stream_result(self):
headers={
"charset": "utf-8",
"Content-Type": "application/x-www-form-urlencoded",
"X-Request-ID": self.job["id"],
},
raise_for_status=True,
)
Expand Down