Skip to content

Commit 3bdd67f

Browse files
authored
Support for timeout, retry and proxy from the dsl
* retry, timeout and proxy support * more processing tests before request sent * Dependency updates ✅ #417 - idna 3.10 → 3.15 ✅ #416 - faker 37.12.0 → 40.18.0 ✅ #415 - jsonschema 4.25.1 → 4.26.0 ✅ #414 - pytest 8.4.2 → 9.0.3 ✅ #413 - requests-aws4auth 1.3.1 → 1.3.2 ✅ #408 - python 3.13-slim → 3.14-slim ✅ #401 - urllib3 2.2.3 → 2.7.0 * Update Python version to 3.14 in CI workflow Examples added: - Simple GraphQL query - Query with variables - Mutation example
1 parent 76cc256 commit 3bdd67f

30 files changed

Lines changed: 1473 additions & 159 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
- name: Install Python 3
1717
uses: actions/setup-python@v5
1818
with:
19-
python-version: 3.11
19+
python-version: 3.14
2020
- uses: Gr1N/setup-poetry@v8
2121
- name: Install dependencies
2222
run: |
@@ -52,4 +52,4 @@ jobs:
5252
# alert-threshold: '150%'
5353
# fail-on-alert: true
5454
# github-token: ${{ secrets.PERSONAL_GITHUB_TOKEN }}
55-
# comment-on-alert: true
55+
# comment-on-alert: true

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.13
1+
FROM python:3.14-slim
22
LABEL io.whalebrew.config.networks '["host"]'
33
ADD pyproject.toml /app/
44
WORKDIR /app

Dockerfile.test

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.13-slim
1+
FROM python:3.14-slim
22

33
# Install system dependencies including build tools for compiling Python packages
44
RUN apt-get update && apt-get install -y --no-install-recommends \

dothttp/http.tx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ HTTP:
2929
urlwrap=URL
3030
(authwrap=AUTHWRAP)?
3131
(certificate = CERTAUTH)?
32+
(timeout=TIMEOUT)?
33+
(retry=RETRY)?
34+
(proxy=PROXY)?
3235
(lines *= LINE)?
3336
(payload=PAYLOAD)?
3437
(output=TOFILE)?
@@ -133,6 +136,23 @@ AZURECLIAUTH:
133136
'azurecli' '(' (('scope' '=')? scope=DotString )? ')'
134137
;
135138

139+
TIMEOUT:
140+
'timeout' '(' timeout_seconds=DotString ')'
141+
;
142+
143+
RETRY:
144+
'retry' '(' retry_params*=RETRY_PARAM[','] ','? ')'
145+
;
146+
147+
RETRY_PARAM:
148+
('total' '=' total=DotString)
149+
| ('status_forcelist' '=' '[' status_forcelist*=DotString[','] ','? ']')
150+
| ('backoff_factor' '=' backoff_factor=DotString)
151+
;
152+
153+
PROXY:
154+
'proxy' '(' proxy=DotString ')'
155+
;
136156

137157
EXTRA_ARG:
138158
// there can be more

dothttp/models/computed.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ class HttpDef:
8888
test_script: str = ""
8989
test_script_lang: ScriptType = ScriptType.PYTHON
9090
proxy: Optional[Dict[str, str]] = None
91+
timeout: Optional[float] = None
92+
# Retry fields - most commonly used urllib3.Retry parameters
93+
retry_total: Optional[int] = None
94+
retry_status_forcelist: Optional[List[int]] = None
95+
retry_backoff_factor: Optional[float] = None
96+
custom_proxy: Optional[str] = None
9197

9298
def get_har(self):
9399
if self.auth:

dothttp/models/parse_models.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,30 @@ class AuthWrap:
126126
azure_auth: Optional[AzureAuthWrap] = None
127127

128128

129+
@dataclass
130+
class Timeout:
131+
timeout_seconds: str
132+
133+
134+
@dataclass
135+
class RetryParam:
136+
"""Most commonly used urllib3.Retry parameters"""
137+
total: Optional[str] = None
138+
status_forcelist: Optional[List[str]] = None
139+
backoff_factor: Optional[str] = None
140+
141+
142+
@dataclass
143+
class Retry:
144+
"""Maps to commonly-used urllib3.Retry parameters"""
145+
retry_params: Optional[List[RetryParam]] = None
146+
147+
148+
@dataclass
149+
class Proxy:
150+
proxy: str
151+
152+
129153
@dataclass
130154
class Query:
131155
key: str
@@ -225,6 +249,9 @@ class Http:
225249
lines: Optional[List[Line]]
226250
payload: Optional[Payload]
227251
output: Optional[ToFile]
252+
timeout: Optional[Timeout] = None
253+
retry: Optional[Retry] = None
254+
proxy: Optional[Proxy] = None
228255
description: Optional[str] = None
229256
extra_args: Optional[List[ExtraArg]] = field(default_factory=lambda: [])
230257
named_args: Optional[List[NamedArg]] = field(default_factory=lambda: [])

dothttp/parse/__init__.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,53 @@ def _load_proxy_details(
467467
else None
468468
)
469469

470+
def load_timeout(self):
471+
"""Load timeout from current request or parent with inheritance"""
472+
timeout_wrap = self.get_current_or_base("timeout")
473+
if timeout_wrap:
474+
timeout_str = self.get_updated_content(timeout_wrap.timeout_seconds)
475+
try:
476+
self.httpdef.timeout = float(timeout_str)
477+
request_logger.debug(f"timeout set to {self.httpdef.timeout} seconds")
478+
except ValueError:
479+
base_logger.error(f"Invalid timeout value: {timeout_str}")
480+
481+
def load_retry(self):
482+
"""Load retry configuration - maps to commonly-used urllib3.Retry parameters"""
483+
retry_wrap = self.get_current_or_base("retry")
484+
if not retry_wrap or not retry_wrap.retry_params:
485+
return
486+
487+
try:
488+
# Process each retry parameter
489+
for param in retry_wrap.retry_params:
490+
if param.total:
491+
self.httpdef.retry_total = int(self.get_updated_content(param.total))
492+
493+
if param.status_forcelist:
494+
self.httpdef.retry_status_forcelist = [
495+
int(self.get_updated_content(code))
496+
for code in param.status_forcelist
497+
]
498+
499+
if param.backoff_factor:
500+
self.httpdef.retry_backoff_factor = float(self.get_updated_content(param.backoff_factor))
501+
502+
request_logger.debug(
503+
f"retry configured: total={self.httpdef.retry_total}, "
504+
f"backoff_factor={self.httpdef.retry_backoff_factor}"
505+
)
506+
except (ValueError, TypeError) as e:
507+
base_logger.error(f"Invalid retry configuration: {e}")
508+
509+
def load_custom_proxy(self):
510+
"""Load custom proxy from current request or parent with inheritance"""
511+
proxy_wrap = self.get_current_or_base("proxy")
512+
if proxy_wrap:
513+
proxy_url = self.get_updated_content(proxy_wrap.proxy)
514+
self.httpdef.custom_proxy = proxy_url
515+
request_logger.debug(f"custom proxy set: {proxy_url}")
516+
470517
def load_headers(self):
471518
"""
472519
entrypoints
@@ -935,6 +982,9 @@ def load_def(self):
935982
self.load_payload()
936983
self.load_auth()
937984
self.load_proxy()
985+
self.load_timeout()
986+
self.load_retry()
987+
self.load_custom_proxy()
938988
self.load_certificate()
939989
self.load_output()
940990
self._loaded = True

dothttp/parse/request_base.py

Lines changed: 119 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import jstyleson as json
1010
from requests import PreparedRequest, Response, Session
11+
from requests.adapters import HTTPAdapter
12+
from urllib3.util.retry import Retry
1113

1214
# this is bad, loading private stuff. find a better way
1315
from requests.exceptions import SSLError
@@ -123,7 +125,7 @@ def get_cookie(self):
123125

124126
def get_session(self):
125127
if self.httpdef.session_clear:
126-
# calle should close session
128+
# caller should close session
127129
# TODO
128130
return get_new_session()
129131
session = self.global_session
@@ -346,6 +348,30 @@ def format_http(http: Http, property_util: Optional[PropertyProvider]=None):
346348
output_str += f'{new_line}azurespcert("tenant_id={cert_auth.tenant_id}", client_id="{cert_auth.client_id}", client_secret="{cert_auth.certificate_path}", scope="{cert_auth.scope}")'
347349
else:
348350
output_str += f'{new_line}azurecli( scope = "{azure_auth.scope}")'
351+
352+
# Format timeout
353+
if timeout := http.timeout:
354+
output_str += f'{new_line}timeout({timeout.timeout_seconds})'
355+
356+
# Format retry
357+
if retry := http.retry:
358+
if retry.retry_params:
359+
params = []
360+
for param in retry.retry_params:
361+
if param.total:
362+
params.append(f'total={param.total}')
363+
if param.backoff_factor:
364+
params.append(f'backoff_factor={param.backoff_factor}')
365+
if param.status_forcelist:
366+
codes = ', '.join(param.status_forcelist)
367+
params.append(f'status_forcelist=[{codes}]')
368+
if params:
369+
output_str += f'{new_line}retry({", ".join(params)})'
370+
371+
# Format proxy
372+
if proxy := http.proxy:
373+
output_str += f'{new_line}proxy({apply_quote_or_unquote(proxy.proxy)})'
374+
349375
if lines := http.lines:
350376

351377
def check_for_quotes(line):
@@ -555,10 +581,59 @@ def func(data):
555581
)
556582
eprint("output file close failed")
557583

584+
def _create_retry_adapter(self):
585+
"""
586+
Create a retry adapter based on httpdef retry settings.
587+
588+
Returns HTTPAdapter with retry configuration, or None if no retry is configured.
589+
This adapter can be used directly with adapter.send() without mounting on session.
590+
591+
Passes parameters directly to urllib3.Retry without additional processing.
592+
"""
593+
# Check if any retry configuration exists
594+
if self.httpdef.retry_total is None:
595+
return None # No retry configuration
596+
597+
# Build kwargs for Retry, passing only configured parameters
598+
retry_kwargs = {}
599+
600+
if self.httpdef.retry_total is not None:
601+
retry_kwargs['total'] = self.httpdef.retry_total
602+
603+
if self.httpdef.retry_status_forcelist is not None:
604+
retry_kwargs['status_forcelist'] = self.httpdef.retry_status_forcelist
605+
606+
if self.httpdef.retry_backoff_factor is not None:
607+
retry_kwargs['backoff_factor'] = self.httpdef.retry_backoff_factor
608+
609+
# Always set raise_on_status=False for API testing tools
610+
# Users want to see the response regardless of status code
611+
retry_kwargs['raise_on_status'] = False
612+
613+
# Create retry strategy with configured parameters
614+
retry_strategy = Retry(**retry_kwargs)
615+
616+
# Create adapter with retry strategy
617+
adapter = HTTPAdapter(max_retries=retry_strategy)
618+
619+
request_logger.debug(f"Retry adapter created with: {retry_kwargs}")
620+
621+
return adapter
622+
558623
def get_response(self):
624+
"""
625+
Get HTTP response with optional retry support via urllib3.Retry.
626+
627+
Uses adapter.send() directly if retry is configured, avoiding session
628+
state modification and ensuring thread-safety in concurrent environments.
629+
"""
559630
session = self.get_session()
560631
request = self.get_request()
561632
session.cookies = request._cookies
633+
634+
# Create retry adapter if configured (doesn't modify session)
635+
retry_adapter = self._create_retry_adapter()
636+
562637
if self.httpdef.p12:
563638
session.mount(
564639
request.url,
@@ -572,25 +647,56 @@ def get_response(self):
572647
cert = tuple(self.httpdef.certificate)
573648
else:
574649
cert = None
575-
resp: Response = session.send(
576-
request,
577-
cert=cert,
578-
verify=not self.httpdef.allow_insecure,
579-
proxies=self.httpdef.proxy,
580-
# stream=True
581-
)
650+
651+
# Prepare kwargs for session.send
652+
send_kwargs = {
653+
'cert': cert,
654+
'verify': not self.httpdef.allow_insecure,
655+
}
656+
657+
# Handle proxy configuration
658+
# Priority: custom_proxy (from DSL) > proxy (from named_args)
659+
if self.httpdef.custom_proxy:
660+
send_kwargs['proxies'] = {
661+
'http': self.httpdef.custom_proxy,
662+
'https': self.httpdef.custom_proxy,
663+
}
664+
request_logger.debug(f"Using custom proxy: {self.httpdef.custom_proxy}")
665+
elif self.httpdef.proxy:
666+
send_kwargs['proxies'] = self.httpdef.proxy
667+
668+
# Add timeout if configured
669+
if self.httpdef.timeout:
670+
send_kwargs['timeout'] = self.httpdef.timeout
671+
672+
# Use retry adapter if configured, otherwise use session.send()
673+
if retry_adapter:
674+
# Call adapter.send() directly - no need to mount on session!
675+
# This keeps session state clean and is thread-safe
676+
resp: Response = retry_adapter.send(request, **send_kwargs)
677+
else:
678+
# Normal request without retry
679+
resp: Response = session.send(request, **send_kwargs)
582680
except UnicodeEncodeError:
583681
# for Chinese, smiley all other default encode converts into latin-1
584682
# as latin-1 didn't consist of those characters it will fail
585683
# in those scenarios, request will try to encode with utf-8
586684
# as a last resort, it may not be correct solution. may be it is.
587685
# for now proceeding with this
588686
request.prepare_body(request.body.encode("utf-8"), files=None)
589-
resp: Response = session.send(
590-
request,
591-
cert=self.httpdef.certificate,
592-
verify=not self.httpdef.allow_insecure,
593-
)
687+
688+
send_kwargs = {
689+
'cert': self.httpdef.certificate,
690+
'verify': not self.httpdef.allow_insecure,
691+
}
692+
if self.httpdef.timeout:
693+
send_kwargs['timeout'] = self.httpdef.timeout
694+
695+
# Use retry adapter if configured
696+
if retry_adapter:
697+
resp: Response = retry_adapter.send(request, **send_kwargs)
698+
else:
699+
resp: Response = session.send(request, **send_kwargs)
594700
# in case of ssl self signed error, try to catch it and add more info
595701
# to user
596702
except SSLError as e:

examples/graphql/github_api.http

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# GraphQL Examples with GitHub API
2+
# GraphQL works out of the box - it's just POST with JSON!
3+
4+
# Simple query
5+
@name("viewer-info")
6+
POST https://api.github.com/graphql
7+
"Authorization": "Bearer {{GITHUB_TOKEN}}"
8+
9+
json({
10+
"query": "query { viewer { login name email bio } }"
11+
})
12+
13+
14+
# Query with variables
15+
@name("get-user-repos")
16+
POST https://api.github.com/graphql
17+
"Authorization": "Bearer {{GITHUB_TOKEN}}"
18+
19+
json({
20+
"query": "query GetUserRepos($login: String!) { user(login: $login) { name login bio avatarUrl repositories(first: 10, orderBy: {field: STARGAZERS, direction: DESC}) { totalCount nodes { name description stargazerCount forkCount url } } } }",
21+
"variables": {
22+
"login": "cedric05"
23+
}
24+
})
25+
26+
27+
# Mutation example
28+
@name("add-star")
29+
POST https://api.github.com/graphql
30+
"Authorization": "Bearer {{GITHUB_TOKEN}}"
31+
32+
json({
33+
"query": "mutation AddStar($repositoryId: ID!) { addStar(input: {starrableId: $repositoryId}) { starrable { stargazerCount } } }",
34+
"variables": {
35+
"repositoryId": "{{repoId}}"
36+
}
37+
})

0 commit comments

Comments
 (0)