Skip to content

Commit 8eaaed2

Browse files
avoid double serializing json by capturing request size in rest http client and adjusting surrounding calls to be able to pass the info back up. additional improvements based on pr feedback + tests
1 parent 620b895 commit 8eaaed2

11 files changed

Lines changed: 208 additions & 59 deletions

File tree

src/conductor/client/http/api_client.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import base64
24
import datetime
35
import logging
@@ -6,7 +8,7 @@
68
import re
79
import tempfile
810
import time
9-
from typing import Dict
11+
from typing import Dict, Optional, TYPE_CHECKING
1012
import uuid
1113

1214
import six
@@ -20,6 +22,9 @@
2022
from conductor.client.http.rest import AuthorizationException
2123
from conductor.client.http.thread import AwaitableThread
2224

25+
if TYPE_CHECKING:
26+
from conductor.client.telemetry.metrics_collector_base import MetricsCollectorBase
27+
2328
logger = logging.getLogger(
2429
Configuration.get_logging_formatted_name(
2530
__name__
@@ -45,7 +50,7 @@ def __init__(
4550
header_name=None,
4651
header_value=None,
4752
cookie=None,
48-
metrics_collector=None
53+
metrics_collector: Optional[MetricsCollectorBase] = None
4954
):
5055
if configuration is None:
5156
configuration = Configuration()
@@ -188,7 +193,8 @@ def __call_api_no_retry(
188193
return (return_data)
189194
else:
190195
return (return_data, response_data.status,
191-
response_data.getheaders())
196+
response_data.getheaders(),
197+
getattr(response_data, 'request_body_size', 0))
192198

193199
def sanitize_for_serialization(self, obj):
194200
"""Builds a JSON POST object.

src/conductor/client/http/async_api_client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import base64
24
import datetime
35
import logging
@@ -6,7 +8,7 @@
68
import re
79
import tempfile
810
import time
9-
from typing import Dict
11+
from typing import Dict, Optional, TYPE_CHECKING
1012
import uuid
1113

1214
import six
@@ -19,6 +21,9 @@
1921
from conductor.client.http import async_rest
2022
from conductor.client.http.async_rest import AuthorizationException
2123

24+
if TYPE_CHECKING:
25+
from conductor.client.telemetry.metrics_collector_base import MetricsCollectorBase
26+
2227
logger = logging.getLogger(
2328
Configuration.get_logging_formatted_name(
2429
__name__
@@ -47,7 +52,7 @@ def __init__(
4752
header_name=None,
4853
header_value=None,
4954
cookie=None,
50-
metrics_collector=None
55+
metrics_collector: Optional[MetricsCollectorBase] = None
5156
):
5257
if configuration is None:
5358
configuration = Configuration()

src/conductor/client/http/rest.py

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def __init__(self, resp):
2929
self.reason = getattr(resp, 'reason_phrase', '') or self._get_reason_phrase(resp.status_code)
3030
self.data = resp.text # eagerly read body
3131
self.headers = resp.headers
32+
self.request_body_size = 0
3233
# Break httpx Response <-> BoundSyncStream reference cycle (issue #395)
3334
resp.stream = None
3435
try:
@@ -219,36 +220,42 @@ def request(self, method, url, query_params=None, headers=None,
219220
"httpx client was closed before request; re-established a fresh client"
220221
)
221222

223+
# Serialize the request body once, before the retry loop.
224+
# The body never changes between attempts; only the httpx client does.
225+
request_body = None
226+
request_body_size = 0
227+
request_url = url
228+
has_body = method in ['POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']
229+
230+
if has_body:
231+
if query_params:
232+
request_url = url + '?' + urlencode(query_params)
233+
if re.search('json', headers['Content-Type'], re.IGNORECASE) or isinstance(body, str):
234+
request_body = '{}'
235+
if body is not None:
236+
request_body = json.dumps(body)
237+
if isinstance(body, str):
238+
request_body = request_body.strip('"')
239+
request_body_size = len(request_body.encode('utf-8'))
240+
else:
241+
msg = """Cannot prepare a request message for provided
242+
arguments. Please check that your arguments match
243+
declared content type."""
244+
raise ApiException(status=0, reason=msg)
245+
222246
for attempt in range(2):
223247
# Snapshot the client we're about to use so that if the request
224248
# fails we can ask for a reset only if the client is still this
225249
# one (i.e. nobody else healed it in the meantime).
226250
client_at_send = self.connection
227251
try:
228-
# For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE`
229-
if method in ['POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']:
230-
request_url = url
231-
if query_params:
232-
request_url += '?' + urlencode(query_params)
233-
if re.search('json', headers['Content-Type'], re.IGNORECASE) or isinstance(body, str):
234-
request_body = '{}'
235-
if body is not None:
236-
request_body = json.dumps(body)
237-
if isinstance(body, str):
238-
request_body = request_body.strip('"')
239-
r = client_at_send.request(
240-
method, request_url,
241-
content=request_body,
242-
timeout=timeout,
243-
headers=headers
244-
)
245-
else:
246-
# Cannot generate the request from given parameters
247-
msg = """Cannot prepare a request message for provided
248-
arguments. Please check that your arguments match
249-
declared content type."""
250-
raise ApiException(status=0, reason=msg)
251-
# For `GET`, `HEAD`
252+
if has_body:
253+
r = client_at_send.request(
254+
method, request_url,
255+
content=request_body,
256+
timeout=timeout,
257+
headers=headers
258+
)
252259
else:
253260
r = client_at_send.request(
254261
method, url,
@@ -309,6 +316,7 @@ def request(self, method, url, query_params=None, headers=None,
309316

310317
if _preload_content:
311318
r = RESTResponse(r)
319+
r.request_body_size = request_body_size
312320

313321
if r.status == 401 or r.status == 403:
314322
raise AuthorizationException(http_resp=r)

src/conductor/client/orkes/orkes_base_client.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
from __future__ import annotations
2+
13
import logging
4+
from typing import Optional, TYPE_CHECKING
25

36
from conductor.client.configuration.configuration import Configuration
7+
8+
if TYPE_CHECKING:
9+
from conductor.client.telemetry.metrics_collector_base import MetricsCollectorBase
410
from conductor.client.http.api.application_resource_api import ApplicationResourceApi
511
from conductor.client.http.api.authorization_resource_api import AuthorizationResourceApi
612
from conductor.client.http.api.gateway_auth_resource_api import GatewayAuthResourceApi
@@ -22,7 +28,7 @@
2228

2329

2430
class OrkesBaseClient(object):
25-
def __init__(self, configuration: Configuration, metrics_collector=None):
31+
def __init__(self, configuration: Configuration, metrics_collector: Optional[MetricsCollectorBase] = None):
2632
self.metrics_collector = metrics_collector
2733
self.api_client = ApiClient(configuration, metrics_collector=metrics_collector)
2834
self.logger = logging.getLogger(

src/conductor/client/orkes/orkes_workflow_client.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from __future__ import annotations
2-
from typing import Optional, List, Dict
2+
from typing import Optional, List, Dict, TYPE_CHECKING
33

44
from conductor.client.configuration.configuration import Configuration
55
from conductor.client.http.models import SkipTaskRequest, WorkflowStatus, \
@@ -14,12 +14,15 @@
1414
from conductor.client.orkes.orkes_base_client import OrkesBaseClient
1515
from conductor.client.workflow_client import WorkflowClient
1616

17+
if TYPE_CHECKING:
18+
from conductor.client.telemetry.metrics_collector_base import MetricsCollectorBase
19+
1720

1821
class OrkesWorkflowClient(OrkesBaseClient, WorkflowClient):
1922
def __init__(
2023
self,
2124
configuration: Configuration,
22-
metrics_collector=None,
25+
metrics_collector: Optional[MetricsCollectorBase] = None,
2326
):
2427
super(OrkesWorkflowClient, self).__init__(configuration, metrics_collector=metrics_collector)
2528

@@ -39,13 +42,10 @@ def start_workflow_by_name(
3942
if priority:
4043
kwargs.update({"priority": priority})
4144

42-
if self.metrics_collector is not None:
43-
try:
44-
self.metrics_collector.measure_workflow_input_payload_size(name, version, input)
45-
except Exception:
46-
pass
4745
try:
48-
return self.workflowResourceApi.start_workflow1(input, name, **kwargs)
46+
data, _status, _headers, request_body_size = (
47+
self.workflowResourceApi.start_workflow1_with_http_info(input, name, **kwargs)
48+
)
4949
except Exception as e:
5050
if self.metrics_collector is not None:
5151
try:
@@ -54,18 +54,20 @@ def start_workflow_by_name(
5454
pass
5555
raise
5656

57-
def start_workflow(self, start_workflow_request: StartWorkflowRequest) -> str:
5857
if self.metrics_collector is not None:
5958
try:
60-
self.metrics_collector.measure_workflow_input_payload_size(
61-
start_workflow_request.name,
62-
start_workflow_request.version,
63-
getattr(start_workflow_request, "input", None),
64-
)
59+
version_str = str(version) if version is not None else ""
60+
self.metrics_collector.record_workflow_input_payload_size(name, version_str, request_body_size)
6561
except Exception:
6662
pass
63+
64+
return data
65+
66+
def start_workflow(self, start_workflow_request: StartWorkflowRequest) -> str:
6767
try:
68-
return self.workflowResourceApi.start_workflow(start_workflow_request)
68+
data, _status, _headers, request_body_size = (
69+
self.workflowResourceApi.start_workflow_with_http_info(start_workflow_request)
70+
)
6971
except Exception as e:
7072
if self.metrics_collector is not None:
7173
try:
@@ -74,6 +76,17 @@ def start_workflow(self, start_workflow_request: StartWorkflowRequest) -> str:
7476
pass
7577
raise
7678

79+
if self.metrics_collector is not None:
80+
try:
81+
version_str = str(start_workflow_request.version) if start_workflow_request.version is not None else ""
82+
self.metrics_collector.record_workflow_input_payload_size(
83+
start_workflow_request.name, version_str, request_body_size,
84+
)
85+
except Exception:
86+
pass
87+
88+
return data
89+
7790
def execute_workflow(
7891
self,
7992
start_workflow_request: StartWorkflowRequest,

src/conductor/client/orkes_clients.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional, TYPE_CHECKING
4+
15
from conductor.client.authorization_client import AuthorizationClient
26
from conductor.client.configuration.configuration import Configuration
7+
8+
if TYPE_CHECKING:
9+
from conductor.client.telemetry.metrics_collector_base import MetricsCollectorBase
310
from conductor.client.integration_client import IntegrationClient
411
from conductor.client.metadata_client import MetadataClient
512
from conductor.client.orkes.orkes_integration_client import OrkesIntegrationClient
@@ -21,7 +28,7 @@
2128

2229

2330
class OrkesClients:
24-
def __init__(self, configuration: Configuration = None, metrics_collector=None):
31+
def __init__(self, configuration: Configuration = None, metrics_collector: Optional[MetricsCollectorBase] = None):
2532
if configuration is None:
2633
configuration = Configuration()
2734
self.configuration = configuration

src/conductor/client/telemetry/canonical_metrics_collector.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,13 @@ def record_task_update_time(self, task_type: str, time_spent: float, status: str
194194

195195
def record_api_request_time(self, method: str, uri: str, status: str, time_spent: float,
196196
metric_uri: str = None) -> None:
197+
resolved_uri = (metric_uri or uri).replace("{tasktype}", "{taskType}")
197198
self._observe_histogram(
198199
name=MetricName.API_REQUEST_TIME_CANONICAL,
199200
documentation=MetricDocumentation.API_REQUEST_TIME_CANONICAL,
200201
labels={
201202
MetricLabel.METHOD: method,
202-
MetricLabel.URI: metric_uri or uri,
203+
MetricLabel.URI: resolved_uri,
203204
MetricLabel.STATUS: status,
204205
},
205206
value=time_spent,

src/conductor/client/workflow/executor/workflow_executor.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22
import uuid
3-
from typing import Any, Dict, List, Optional
3+
from typing import Any, Dict, List, Optional, TYPE_CHECKING
44

55
from typing_extensions import Self
66

@@ -25,9 +25,12 @@
2525
)
2626
from conductor.client.orkes.orkes_workflow_client import OrkesWorkflowClient
2727

28+
if TYPE_CHECKING:
29+
from conductor.client.telemetry.metrics_collector_base import MetricsCollectorBase
30+
2831

2932
class WorkflowExecutor:
30-
def __init__(self, configuration: Configuration, metrics_collector=None) -> Self:
33+
def __init__(self, configuration: Configuration, metrics_collector: Optional[MetricsCollectorBase] = None) -> Self:
3134
api_client = ApiClient(configuration, metrics_collector=metrics_collector)
3235
self.metadata_client = MetadataResourceApi(api_client)
3336
self.task_client = TaskResourceApi(api_client)

tests/unit/api_client/test_api_client_coverage.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,40 @@ def test_request_with_metrics_collector(self):
683683
self.assertEqual(call_args[1]['method'], 'GET')
684684
self.assertEqual(call_args[1]['status'], '200')
685685

686+
def test_request_passes_metric_uri_to_collector(self):
687+
"""metric_uri kwarg is forwarded to record_api_request_time on success."""
688+
metrics_collector = Mock()
689+
with patch.object(ApiClient, '_ApiClient__refresh_auth_token'):
690+
client = ApiClient(configuration=self.config, metrics_collector=metrics_collector)
691+
692+
with patch.object(client.rest_client, 'GET', return_value=Mock(status=200)):
693+
client.request(
694+
'GET', 'http://localhost:8080/api/workflow/test-id',
695+
metric_uri='/api/workflow/{workflowId}',
696+
)
697+
698+
call_args = metrics_collector.record_api_request_time.call_args
699+
self.assertEqual(call_args[1]['metric_uri'], '/api/workflow/{workflowId}')
700+
701+
def test_request_passes_metric_uri_to_collector_on_error(self):
702+
"""metric_uri kwarg is forwarded to record_api_request_time on error."""
703+
metrics_collector = Mock()
704+
with patch.object(ApiClient, '_ApiClient__refresh_auth_token'):
705+
client = ApiClient(configuration=self.config, metrics_collector=metrics_collector)
706+
707+
error = Exception('Test error')
708+
error.status = 500
709+
710+
with patch.object(client.rest_client, 'GET', side_effect=error):
711+
with self.assertRaises(Exception):
712+
client.request(
713+
'GET', 'http://localhost:8080/api/workflow/test-id',
714+
metric_uri='/api/workflow/{workflowId}',
715+
)
716+
717+
call_args = metrics_collector.record_api_request_time.call_args
718+
self.assertEqual(call_args[1]['metric_uri'], '/api/workflow/{workflowId}')
719+
686720
def test_request_with_metrics_collector_on_error(self):
687721
"""Test request method with metrics collector on error"""
688722
metrics_collector = Mock()

0 commit comments

Comments
 (0)