Skip to content

Commit 41d0822

Browse files
committed
Updates to move SDK closer to initial developer preview.
Changes ======= * Add retry logic * Cleaned up error handling * Reorganized RequestContext logic * Updated tests * Updated pre-commit configuration and ensured all stages are passing
1 parent 9364517 commit 41d0822

73 files changed

Lines changed: 1069 additions & 508 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.pre-commit-config.yaml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,54 @@ repos:
2020
# - id: ruff-format
2121
# types_or: [ python, pyi ]
2222
# Compile requirements
23+
- repo: https://github.com/PyCQA/bandit
24+
rev: 1.8.6
25+
hooks:
26+
- id: bandit
27+
exclude: |
28+
(?x)^(
29+
acouchbase_analytics/tests/|
30+
couchbase_analytics/tests/|
31+
tests/|
32+
couchbase_analytics_version.py
33+
)
34+
args:
35+
[
36+
--quiet
37+
]
38+
- repo: https://github.com/PyCQA/isort
39+
rev: 5.13.2
40+
hooks:
41+
- id: isort
42+
exclude: |
43+
(?x)^(
44+
deps/|
45+
src/
46+
)
47+
args:
48+
[
49+
"--multi-line 1",
50+
"--force-grid-wrap 3",
51+
"--use-parentheses True",
52+
"--ensure-newline-before-comments True",
53+
"--line-length 120",
54+
"--order-by-type True"
55+
]
56+
- repo: local
57+
hooks:
58+
- id: mypy
59+
name: mypy
60+
entry: "./run-mypy"
61+
language: python
62+
additional_dependencies:
63+
- mypy~=1.16.1
64+
- pytest~=8.3.5
65+
- httpx~=0.28.1
66+
- aiohttp~=3.11.10
67+
types:
68+
- python
69+
require_serial: true
70+
verbose: true
2371
- repo: https://github.com/astral-sh/uv-pre-commit
2472
# uv version.
2573
rev: 0.7.19

acouchbase_analytics/cluster.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
from __future__ import annotations
1717

1818
import sys
19-
from typing import TYPE_CHECKING, Awaitable, Optional
19+
from typing import (TYPE_CHECKING,
20+
Awaitable,
21+
Optional)
2022

2123
if sys.version_info < (3, 10):
2224
from typing_extensions import TypeAlias

acouchbase_analytics/cluster.pyi

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ else:
2323

2424
from acouchbase_analytics.database import AsyncDatabase
2525
from couchbase_analytics.credential import Credential
26-
from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs
26+
from couchbase_analytics.options import (ClusterOptions,
27+
ClusterOptionsKwargs,
28+
QueryOptions,
29+
QueryOptionsKwargs)
2730
from couchbase_analytics.result import AsyncQueryResult
2831

2932
class AsyncCluster:

acouchbase_analytics/database.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
else:
2424
from typing import TypeAlias
2525

26-
2726
from acouchbase_analytics.scope import AsyncScope
2827

2928
if TYPE_CHECKING:

acouchbase_analytics/protocol/_core/async_json_stream.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,14 @@
1818
from typing import AsyncIterator, Optional
1919

2020
import ijson
21-
from anyio import EndOfStream, Event, create_memory_object_stream
21+
from anyio import (EndOfStream,
22+
Event,
23+
create_memory_object_stream)
2224

2325
from acouchbase_analytics.protocol._core.async_json_token_parser import AsyncJsonTokenParser
24-
from couchbase_analytics.common._core.json_parsing import (
25-
JsonParsingError,
26-
JsonStreamConfig,
27-
ParsedResult,
28-
ParsedResultType,
29-
)
26+
from couchbase_analytics.common._core.json_parsing import (JsonStreamConfig,
27+
ParsedResult,
28+
ParsedResultType)
3029
from couchbase_analytics.common.errors import AnalyticsError
3130

3231

@@ -133,7 +132,11 @@ async def _process_token_stream(self) -> None:
133132
except StopAsyncIteration:
134133
self._token_stream_exhausted = True
135134
except ijson.common.IncompleteJSONError as ex:
136-
raise JsonParsingError(cause=ex) from None
135+
# TODO: log this error
136+
self._token_stream_exhausted = True
137+
await self._send_to_stream(ParsedResult(str(ex).encode('utf-8'), ParsedResultType.ERROR), close=True)
138+
self._handle_notification(ParsedResultType.ERROR)
139+
return
137140

138141

139142
if self._token_stream_exhausted:

acouchbase_analytics/protocol/_core/async_json_token_parser.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,18 @@
1515

1616
from __future__ import annotations
1717

18-
from typing import Any, Callable, Coroutine, List, Optional
18+
from typing import (Any,
19+
Callable,
20+
Coroutine,
21+
List,
22+
Optional)
1923

20-
from couchbase_analytics.common._core.json_token_parser_base import (
21-
POP_EVENTS,
22-
START_EVENTS,
23-
VALUE_TOKENS,
24-
JsonTokenParserBase,
25-
ParsingState,
26-
TokenType,
27-
)
24+
from couchbase_analytics.common._core.json_token_parser_base import (POP_EVENTS,
25+
START_EVENTS,
26+
VALUE_TOKENS,
27+
JsonTokenParserBase,
28+
ParsingState,
29+
TokenType)
2830

2931

3032
class AsyncJsonTokenParser(JsonTokenParserBase):

acouchbase_analytics/protocol/_core/client_adapter.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
from typing import TYPE_CHECKING, Optional
2020
from uuid import uuid4
2121

22-
from httpx import URL, AsyncClient, BasicAuth, Response
22+
from httpx import (URL,
23+
AsyncClient,
24+
BasicAuth,
25+
Response)
2326

2427
from couchbase_analytics.common.credential import Credential
2528
from couchbase_analytics.common.deserializer import Deserializer
@@ -138,11 +141,8 @@ async def send_request(self, request: QueryRequest) -> Response:
138141
if not hasattr(self, '_client'):
139142
raise RuntimeError('Client not created yet')
140143

141-
# if request.url is None:
142-
# raise ValueError('Request URL cannot be None')
143-
144144
url = URL(scheme=request.url.scheme,
145-
host=request.url.host,
145+
host=request.url.ip,
146146
port=request.url.port,
147147
path=request.url.path,)
148148
req = self._client.build_request(request.method,

acouchbase_analytics/protocol/_core/net_utils.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,19 @@
1616
from __future__ import annotations
1717

1818
import socket
19-
from ipaddress import IPv4Address, IPv6Address, ip_address
19+
from ipaddress import (IPv4Address,
20+
IPv6Address,
21+
ip_address)
2022
from random import choice
21-
from typing import Optional, Set, Union
23+
from typing import Optional, Union
2224

2325
import anyio
2426

2527
from acouchbase_analytics.protocol.errors import ErrorMapper
2628

2729

2830
@ErrorMapper.handle_socket_error_async
29-
async def get_request_ip_async(host: str,
30-
port: int,
31-
previous_ips: Optional[Set[str]]=None) -> Optional[str]:
31+
async def get_request_ip_async(host: str, port: int) -> str:
3232
# Lets not call getaddrinfo, if the host is already an IP address
3333
try:
3434
ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host)
@@ -40,18 +40,11 @@ async def get_request_ip_async(host: str,
4040
if host == 'localhost':
4141
ip = '127.0.0.1'
4242

43-
if previous_ips is None:
44-
previous_ips = set()
45-
4643
if not ip:
4744
result = await anyio.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC)
48-
try:
49-
res_ip = choice([addr[4][0] for addr in result if addr[4][0] not in previous_ips])
50-
ip = str(res_ip)
51-
except IndexError:
52-
ip = None
45+
res_ip = choice([addr[4][0] for addr in result]) # nosec B311
46+
ip = str(res_ip)
5347
else:
54-
ip_str = str(ip) if not isinstance(ip, str) else ip
55-
ip = None if ip_str in previous_ips else ip_str
48+
ip = str(ip)
5649

5750
return ip

acouchbase_analytics/protocol/_core/request_context.py

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,33 @@
33
import json
44
from asyncio import CancelledError, Task
55
from types import TracebackType
6-
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, List, Optional, Type, Union
6+
from typing import (TYPE_CHECKING,
7+
Any,
8+
Awaitable,
9+
Callable,
10+
Coroutine,
11+
Dict,
12+
List,
13+
Optional,
14+
Type,
15+
Union)
716
from uuid import uuid4
817

918
import anyio
1019
from httpx import Response as HttpCoreResponse
1120
from httpx import TimeoutException
1221

13-
from acouchbase_analytics.protocol._core.anyio_utils import AsyncBackend, current_async_library, get_time
22+
from acouchbase_analytics.protocol._core.anyio_utils import (AsyncBackend,
23+
current_async_library,
24+
get_time)
1425
from acouchbase_analytics.protocol._core.async_json_stream import AsyncJsonStream
1526
from acouchbase_analytics.protocol._core.net_utils import get_request_ip_async
16-
from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType
27+
from couchbase_analytics.common._core import (JsonStreamConfig,
28+
ParsedResult,
29+
ParsedResultType)
1730
from couchbase_analytics.common._core.error_context import ErrorContext
18-
from couchbase_analytics.common.errors import AnalyticsError, InvalidCredentialError
31+
from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator
32+
from couchbase_analytics.common.errors import AnalyticsError
1933
from couchbase_analytics.common.streaming import StreamingState
2034
from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS
2135
from couchbase_analytics.protocol.errors import ErrorMapper
@@ -37,6 +51,7 @@ def __init__(self,
3751
self._client_adapter = client_adapter
3852
self._request = request
3953
self._backend = backend or current_async_library()
54+
self._backoff_calc = DefaultBackoffCalculator()
4055
self._error_ctx = ErrorContext(num_attempts=0,
4156
method=request.method,
4257
statement=request.get_request_statement())
@@ -85,6 +100,10 @@ def request_error(self) -> Optional[Union[BaseException, Exception]]:
85100
def request_state(self) -> StreamingState:
86101
return self._request_state
87102

103+
@property
104+
def retry_limit_exceeded(self) -> bool:
105+
return self.error_context.num_attempts > self._request.max_retries
106+
88107
@property
89108
def results_or_errors_type(self) -> ParsedResultType:
90109
return self._json_stream.results_or_errors_type
@@ -137,10 +156,12 @@ def _maybe_set_request_error(self,
137156
self._request_error = exc_val
138157

139158
async def _process_error(self,
140-
json_data: List[Dict[str, Any]],
159+
json_data: Union[str, List[Dict[str, Any]]],
141160
handle_context_shutdown: Optional[bool]=False) -> None:
142161
self._request_state = StreamingState.Error
143-
if not isinstance(json_data, list):
162+
if isinstance(json_data, str):
163+
self._request_error = ErrorMapper.build_error_from_http_status_code(json_data, self._error_ctx)
164+
elif not isinstance(json_data, list):
144165
self._request_error = AnalyticsError('Cannot parse error response; expected JSON array',
145166
context=str(self._error_ctx))
146167
else:
@@ -154,7 +175,6 @@ def _reset_stream(self) -> None:
154175
if hasattr(self, '_json_stream'):
155176
del self._json_stream
156177
self._request_state = StreamingState.ResetAndNotStarted
157-
self._request.previous_ips = set()
158178
self._stage_completed = None
159179
self._cancel_scope_deadline_updated = False
160180

@@ -197,6 +217,9 @@ async def _wait_for_stage_to_complete(self) -> None:
197217
return
198218
await self._stage_completed.wait()
199219

220+
def calculate_backoff(self) -> float:
221+
return self._backoff_calc.calculate_backoff(self._error_ctx.num_attempts) / 1000
222+
200223
def cancel_request(self,
201224
fn: Optional[Callable[..., Awaitable[Any]]]=None,
202225
*args: object) -> None:
@@ -273,6 +296,9 @@ def okay_to_delay_and_retry(self, delay: float) -> bool:
273296
if will_time_out:
274297
self._request_state = StreamingState.Timeout
275298
return False
299+
elif self.retry_limit_exceeded:
300+
self._request_state = StreamingState.Error
301+
return False
276302
else:
277303
self._reset_stream()
278304
return True
@@ -296,10 +322,15 @@ async def process_response(self,
296322
# we have all the data, close the core response/stream
297323
await close_handler()
298324

299-
json_response = json.loads(raw_response.value)
300-
if 'errors' in json_response:
301-
await self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown)
302-
return json_response
325+
try:
326+
json_response = json.loads(raw_response.value)
327+
except json.JSONDecodeError:
328+
await self._process_error(str(raw_response.value),
329+
handle_context_shutdown=handle_context_shutdown)
330+
else:
331+
if 'errors' in json_response:
332+
await self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown)
333+
return json_response
303334

304335
async def reraise_after_shutdown(self, err: Exception) -> None:
305336
try:
@@ -309,24 +340,17 @@ async def reraise_after_shutdown(self, err: Exception) -> None:
309340
raise ex from None
310341

311342
async def send_request(self, enable_trace_handling: Optional[bool]=False) -> HttpCoreResponse:
312-
ip = await get_request_ip_async(self._request.url.host, self._request.url.port, self._request.previous_ips)
313-
if ip is None:
314-
attempted_ips = ', '.join(self._request.previous_ips or [])
315-
raise AnalyticsError(message=f'Connect failure. Unable to connect to any resolved IPs: {attempted_ips}.',
316-
context=str(self._error_ctx))
317-
343+
self._error_ctx.update_num_attempts()
344+
ip = await get_request_ip_async(self._request.url.host, self._request.url.port)
318345
if enable_trace_handling is True:
319346
(self._request.update_url(ip, self._client_adapter.analytics_path)
320-
.add_trace_to_extensions(self._trace_handler)
321-
.update_previous_ips(ip))
347+
.add_trace_to_extensions(self._trace_handler))
322348
else:
323-
self._request.update_url(ip, self._client_adapter.analytics_path).update_previous_ips(ip)
349+
self._request.update_url(ip, self._client_adapter.analytics_path)
324350
# TODO: add logging; provide request details (to/from, deadlines, etc.)
325351
self._error_ctx.update_request_context(self._request)
326352
response = await self._client_adapter.send_request(self._request)
327353
self._error_ctx.update_response_context(response)
328-
if response.status_code == 401:
329-
raise InvalidCredentialError(context=str(self._error_ctx))
330354
return response
331355

332356
async def shutdown(self,

0 commit comments

Comments
 (0)