Skip to content

Commit 2c9e319

Browse files
committed
Add support for timeout header
1 parent 12eabe1 commit 2c9e319

6 files changed

Lines changed: 81 additions & 8 deletions

File tree

‎src/datacustomcode/named_credential/types/http_request_builder.py‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,25 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16-
from typing import Dict, Union
16+
from typing import (
17+
Dict,
18+
Optional,
19+
Union,
20+
)
1721

1822
from datacustomcode.named_credential.types.http_method import HTTPMethod
1923
from datacustomcode.named_credential.types.http_request import HTTPRequest
2024

25+
# Control header carrying the per-request callout response timeout (seconds).
26+
CALLOUT_RESPONSE_TIMEOUT_HEADER = "ctx-callout-response-timeout-seconds"
27+
2128

2229
class HTTPRequestBuilder:
2330
def __init__(self) -> None:
2431
self._url = ""
2532
self._method: Union[str, HTTPMethod] = HTTPMethod.GET
2633
self._headers: Dict[str, str] = {}
34+
self._response_timeout_seconds: Optional[int] = None
2735

2836
def set_url(self, url: str) -> "HTTPRequestBuilder":
2937
"""Set the symbolic Named Credential reference.
@@ -44,12 +52,37 @@ def set_method(self, method: Union[str, HTTPMethod]) -> "HTTPRequestBuilder":
4452
return self
4553

4654
def set_headers(self, headers: Dict[str, str]) -> "HTTPRequestBuilder":
55+
"""Set the HTTP headers.
56+
57+
Args:
58+
headers: a dictionary of HTTP headers
59+
"""
4760
self._headers = headers
4861
return self
4962

63+
def set_response_timeout_seconds(self, seconds: int) -> "HTTPRequestBuilder":
64+
"""Set the per-request callout response timeout, in seconds.
65+
66+
The ``ctx-callout-response-timeout-seconds`` control header allows to
67+
configure the timeout for the callout response. This is optional.
68+
If not set, the platform will use the default timeout.
69+
"""
70+
if isinstance(seconds, bool) or not isinstance(seconds, int) or seconds <= 0:
71+
raise ValueError(
72+
"response timeout must be a positive integer number of seconds, "
73+
f"got {seconds!r}."
74+
)
75+
self._response_timeout_seconds = seconds
76+
return self
77+
5078
def build(self) -> HTTPRequest:
79+
headers = dict(self._headers)
80+
if self._response_timeout_seconds is not None:
81+
headers[CALLOUT_RESPONSE_TIMEOUT_HEADER] = str(
82+
self._response_timeout_seconds
83+
)
5184
return HTTPRequest(
5285
url=self._url,
5386
method=self._method, # type: ignore[arg-type]
54-
headers=self._headers,
87+
headers=headers,
5588
)

‎src/datacustomcode/templates/function/example/chunking_with_external_callout/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ request = (
1616
.set_url(CALLOUT_URL)
1717
.set_method(HTTPMethod.POST)
1818
.set_headers({"Content-Type": "application/json"})
19+
.set_response_timeout_seconds(60) # optional: per-callout timeout (seconds)
1920
.build()
2021
)
2122
# Body is sent verbatim (serialize it yourself); the response body is a raw string.

‎src/datacustomcode/templates/function/example/chunking_with_external_callout/entrypoint.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def _analyze_chunk(chunk_text: str, runtime: Runtime) -> dict[str, str]:
108108
.set_url(CALLOUT_URL)
109109
.set_method(HTTPMethod.POST)
110110
.set_headers({"Content-Type": "application/json", "Accept": "application/json"})
111+
.set_response_timeout_seconds(60)
111112
.build()
112113
)
113114

‎src/datacustomcode/templates/script/examples/external_callout/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ request = (
1717
.set_url("callout:gemini") # callout:<NC name>[/<path>]
1818
.set_method(HTTPMethod.POST)
1919
.set_headers({"Content-Type": "application/json"})
20+
.set_response_timeout_seconds(60) # optional: per-callout timeout (seconds)
2021
.build()
2122
)
2223
```

‎src/datacustomcode/templates/script/examples/external_callout/entrypoint.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import json
2727
import logging
2828

29+
from pyspark.sql import Column
2930
from pyspark.sql.functions import (
3031
array,
3132
col,
@@ -62,11 +63,12 @@
6263
.set_url(CALLOUT_URL)
6364
.set_method(HTTPMethod.POST)
6465
.set_headers({"Content-Type": "application/json", "Accept": "application/json"})
66+
.set_response_timeout_seconds(60)
6567
.build()
6668
)
6769

6870

69-
def _gemini_body_col(text_col: "col") -> "col": # noqa: F821
71+
def _gemini_body_col(text_col: Column) -> Column:
7072
"""Build a per-row Gemini ``generateContent`` request body as a JSON string.
7173
7274
Using ``to_json(struct(...))`` keeps the row text properly escaped inside the
@@ -97,7 +99,7 @@ def _summarize_on_driver(client: Client, text: str) -> str:
9799

98100
envelope = json.loads(response.body) if response.body else {}
99101
try:
100-
return envelope["candidates"][0]["content"]["parts"][0]["text"]
102+
return str(envelope["candidates"][0]["content"]["parts"][0]["text"])
101103
except (KeyError, IndexError, TypeError):
102104
return ""
103105

@@ -125,11 +127,11 @@ def main():
125127
col("_callout")["response"]["body"],
126128
"$.candidates[0].content.parts[0].text",
127129
)
128-
130+
129131
df = df.select(
130-
col(_ID_COLUMN).alias("id__c"),
131-
col(_TEXT_COLUMN).alias("description__c"),
132-
col(_KQ_ID_COLUMN).alias("kq_id__c"),
132+
col("id__c").alias("id__c"),
133+
col("Description__c").alias("description__c"),
134+
col("kq_id__c").alias("kq_id__c"),
133135
summary.alias("summary__c"),
134136
col("_callout")["status"].alias("callout_status__c"),
135137
col("_callout")["response"]["status_code"].alias("callout_http_code__c"),

‎tests/test_named_credential.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,41 @@ def test_builder_validates_on_build(self):
111111
with pytest.raises(ValidationError):
112112
HTTPRequestBuilder().set_url("").build()
113113

114+
def test_builder_sets_response_timeout_header(self):
115+
request = (
116+
HTTPRequestBuilder()
117+
.set_url("callout:NC/path")
118+
.set_headers({"Accept": "application/json"})
119+
.set_response_timeout_seconds(60)
120+
.build()
121+
)
122+
assert request.headers == {
123+
"Accept": "application/json",
124+
"ctx-callout-response-timeout-seconds": "60",
125+
}
126+
127+
def test_builder_response_timeout_is_order_independent(self):
128+
# Setting the timeout before set_headers must not be wiped out by the
129+
# header map replacement — build() applies it on top either way.
130+
request = (
131+
HTTPRequestBuilder()
132+
.set_url("callout:NC/path")
133+
.set_response_timeout_seconds(60)
134+
.set_headers({"Accept": "application/json"})
135+
.build()
136+
)
137+
assert request.headers == {
138+
"Accept": "application/json",
139+
"ctx-callout-response-timeout-seconds": "60",
140+
}
141+
142+
@pytest.mark.parametrize("bad", [0, -5, True, 1.5, "30", None])
143+
def test_builder_rejects_invalid_response_timeout(self, bad):
144+
with pytest.raises(ValueError):
145+
HTTPRequestBuilder().set_url(
146+
"callout:NC/path"
147+
).set_response_timeout_seconds(bad)
148+
114149

115150
class TestHTTPResponse:
116151
def test_response_defaults(self):

0 commit comments

Comments
 (0)