Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multi region support #483

Merged
merged 29 commits into from
Oct 18, 2021
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
567199d
Generalize API client to handle multiple D-Wave APIs
randomir Aug 26, 2021
af92a7f
Add metadata api client (/regions resource and model)
randomir Aug 26, 2021
894c48e
Add mock and live tests for Regions resource
randomir Aug 26, 2021
3bcffa9
Add region support to the Client
randomir Aug 27, 2021
81d06ae
Update docs for multi-region
randomir Aug 27, 2021
a9e7ebb
Add ctx mgr interface to resource base so session can be closed
randomir Aug 27, 2021
39886d3
Add config.get_cache_dir()
randomir Aug 27, 2021
01712dc
Add @cached.ondisk, a long-term cache backed by sqlite3
randomir Aug 27, 2021
9d314b9
Cache region metadata on disk
randomir Aug 27, 2021
4a3f2ba
Re-cast region metadata api access errors
randomir Aug 28, 2021
efd00b8
Add/update region/endpoint selection tests
randomir Aug 28, 2021
943edb0
Add end-to-end region selection tests
randomir Aug 30, 2021
1f3e318
Fallback to default sapi endpoint if metadata api is down
randomir Sep 17, 2021
433b562
Improve generic dwave api client exception logging
randomir Oct 13, 2021
30f1247
Make isolated_environ both context manager and decorator
randomir Oct 13, 2021
9663cab
Add support for mutually exclusive config options
randomir Oct 13, 2021
6012ce0
Simplify client by deferring config merge to load_/update_config utils
randomir Oct 13, 2021
cd26e65
Test mutually exclusive config options
randomir Oct 13, 2021
81cfb02
Fix RequestTimeout in LoggingSession to include request
randomir Oct 14, 2021
1c5491b
Keep request history in LoggingSession (up to `history_size`)
randomir Oct 14, 2021
1828a6d
Log details for failed request, even without response
randomir Oct 14, 2021
c352c35
Test session.history tracking
randomir Oct 14, 2021
07c3156
Use py36-compatible version of namedtuple
randomir Oct 14, 2021
96f74a3
Update multi-region docs per review comments
randomir Oct 15, 2021
8f8da94
Add env var to control `metadata_api_endpoint`
randomir Oct 15, 2021
33b3d1c
Use `click.prompt` for CLI user input
randomir Oct 18, 2021
1efcd65
Remove unneeded text input util (replaced with click)
randomir Oct 18, 2021
4e868f7
Prompt for 'region' in 'config create --full' flow
randomir Oct 18, 2021
6019484
Propagate headers from config to Metadata API for regions fetch
randomir Oct 18, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/reference/resources.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Methods
:toctree: generated

Client.from_config
Client.get_regions
Client.get_solver
Client.get_solvers
Client.solvers
Expand Down
13 changes: 11 additions & 2 deletions dwave/cloud/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.

# NOTE: dwave.cloud.api module and submodules considered private for now!


import dwave.cloud.api.client
import dwave.cloud.api.constants
import dwave.cloud.api.exceptions
import dwave.cloud.api.models
import dwave.cloud.api.resources

from dwave.cloud.api.client import SAPIClient
from dwave.cloud.api.resources import Solvers, Problems
from dwave.cloud.api import client
from dwave.cloud.api import constants
from dwave.cloud.api import exceptions
from dwave.cloud.api import models
from dwave.cloud.api import resources

from dwave.cloud.api.client import DWaveAPIClient, SolverAPIClient, MetadataAPIClient
from dwave.cloud.api.resources import Solvers, Problems, Regions
175 changes: 122 additions & 53 deletions dwave/cloud/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.

import logging
from collections import deque, namedtuple

import requests
import urllib3
Expand All @@ -22,7 +23,7 @@
from dwave.cloud.utils import (
TimeoutingHTTPAdapter, BaseUrlSession, user_agent, is_caused_by)

__all__ = ['SAPIClient']
__all__ = ['DWaveAPIClient', 'SolverAPIClient', 'MetadataAPIClient']

logger = logging.getLogger(__name__)

Expand All @@ -47,39 +48,82 @@ def __get__(self, obj, objtype=None):
class LoggingSession(BaseUrlSession):
""":class:`.BaseUrlSession` extended to unify timeout exceptions and to log
all requests (and responses).

In addition to request logging, a history of responses, including exceptions
(up to `history_size`), is kept in :attr:`.history`.
"""

def request(self, method, *args, **kwargs):
def __init__(self, history_size: int = 0, **kwargs):
if not isinstance(history_size, int) or history_size < 0:
raise ValueError("non-negative integer value required for 'history_size'")

self.history = deque([], maxlen=history_size)

super().__init__(**kwargs)

def _request_unified(self, method: str, *args, **kwargs):
# timeout exceptions unified with regular request exceptions
try:
return super().request(method, *args, **kwargs)
except Exception as exc:
if is_caused_by(exc, (requests.exceptions.Timeout,
urllib3.exceptions.TimeoutError)):
raise exceptions.RequestTimeout(
request=getattr(exc, 'request', None),
response=getattr(exc, 'response', None)) from exc
else:
raise

RequestRecord = namedtuple('RequestRecord',
('request', 'response', 'exception'))

def request(self, method: str, *args, **kwargs):
callee = type(self).__name__
logger.trace("[%s] request(%r, *%r, **%r)",
callee, method, args, kwargs)

# unify timeout exceptions
try:
response = super().request(method, *args, **kwargs)
response = self._request_unified(method, *args, **kwargs)

rec = LoggingSession.RequestRecord(
request=response.request, response=response, exception=None)
self.history.append(rec)

except Exception as exc:
logger.trace("[%s] request failed with %r", callee, exc)
logger.debug("[%s] request failed with %r", callee, exc)

if is_caused_by(exc, (requests.exceptions.Timeout,
urllib3.exceptions.TimeoutError)):
raise exceptions.RequestTimeout from exc
else:
raise
req = getattr(exc, 'request', None)
if req:
logger.trace("[%s] failing request=%r", callee,
dict(method=req.method, url=req.url,
headers=req.headers, body=req.body))

res = getattr(exc, 'response', None)
if res:
logger.trace("[%s] failing response=%r", callee,
dict(status_code=res.status_code,
headers=res.headers, text=res.text))

rec = LoggingSession.RequestRecord(
request=req, response=res, exception=exc)
self.history.append(rec)

raise

logger.trace("[%s] request(...) = (code=%r, body=%r)",
callee, response.status_code, response.text)

return response


class SAPIClient:
"""Low-level SAPI client, as a thin wrapper around `requests.Session`,
that handles SAPI specifics like authentication and response parsing.
class DWaveAPIClient:
"""Low-level client for D-Wave APIs. A thin wrapper around
`requests.Session` that handles API specifics such as authentication,
response and error parsing, retrying, etc.
"""

DEFAULTS = {
'endpoint': constants.DEFAULT_API_ENDPOINT,
'endpoint': None,
'token': None,
'cert': None,

Expand All @@ -97,12 +141,15 @@ class SAPIClient:

# proxy urls, see :attr:`requests.Session.proxies`
'proxies': None,

# number of most recent request records to keep in :attr:`.session.history`
'history_size': 0,
}

# client instance config, populated on init from kwargs overridding DEFAULTS
config = None

# User-Agent string used in SAPI requests, as returned by
# User-Agent string used in API requests, as returned by
# :meth:`~dwave.cloud.utils.user_agent`, computed on first access and
# cached for the lifespan of the class.
# TODO: consider exposing "user_agent" config parameter
Expand All @@ -115,42 +162,8 @@ def __init__(self, **config):

self.session = self._create_session(self.config)

@classmethod
def from_client_config(cls, client):
"""Create SAPI client instance configured from a
:class:`~dwave.cloud.client.base.Client' instance.
"""

headers = client.headers.copy()
if client.connection_close:
headers.update({'Connection': 'close'})

opts = dict(
endpoint=client.endpoint,
token=client.token,
cert=client.client_cert,
timeout=client.request_timeout,
proxies=dict(
http=client.proxy,
https=client.proxy,
),
retry=dict(
total=client.http_retry_total,
connect=client.http_retry_connect,
read=client.http_retry_read,
redirect=client.http_retry_redirect,
status=client.http_retry_status,
raise_on_redirect=True,
raise_on_status=True,
respect_retry_after_header=True,
backoff_factor=client.http_retry_backoff_factor,
backoff_max=client.http_retry_backoff_max,
),
headers=client.headers,
verify=not client.permissive_ssl,
)

return cls(**opts)
def close(self):
self.session.close()

@staticmethod
def _retry_config(backoff_max=None, **kwargs):
Expand All @@ -172,11 +185,14 @@ def _create_session(cls, config):
# allow endpoint path to not end with /
# (handle incorrect user input when merging paths, see rfc3986, sec 5.2.3)
endpoint = config['endpoint']
if not endpoint:
raise ValueError("API endpoint undefined")
if not endpoint.endswith('/'):
endpoint += '/'

# configure request timeout and retries
session = LoggingSession(base_url=endpoint)
history_size = config['history_size']
session = LoggingSession(base_url=endpoint, history_size=history_size)
timeout = config['timeout']
retry = config['retry']
session.mount('http://',
Expand Down Expand Up @@ -206,7 +222,7 @@ def _create_session(cls, config):
session.hooks['response'].append(cls._raise_for_status)

# debug log
logger.debug("create_session from config={!r}".format(config))
logger.debug(f"{cls.__name__} session created using config={config!r}")

return session

Expand Down Expand Up @@ -262,3 +278,56 @@ def _raise_for_status(response, **kwargs):
raise exceptions.InternalServerError(**kw)
else:
raise exceptions.RequestError(**kw)


class SolverAPIClient(DWaveAPIClient):
"""Client for D-Wave's Solver API."""

randomir marked this conversation as resolved.
Show resolved Hide resolved
def __init__(self, **config):
config.setdefault('endpoint', constants.DEFAULT_SOLVER_API_ENDPOINT)
super().__init__(**config)

@classmethod
def from_client_config(cls, client):
"""Create SAPI client instance configured from a
:class:`~dwave.cloud.client.base.Client' instance.
"""

headers = client.headers.copy()
if client.connection_close:
headers.update({'Connection': 'close'})

opts = dict(
endpoint=client.endpoint,
token=client.token,
cert=client.client_cert,
timeout=client.request_timeout,
proxies=dict(
http=client.proxy,
https=client.proxy,
),
retry=dict(
total=client.http_retry_total,
connect=client.http_retry_connect,
read=client.http_retry_read,
redirect=client.http_retry_redirect,
status=client.http_retry_status,
raise_on_redirect=True,
raise_on_status=True,
respect_retry_after_header=True,
backoff_factor=client.http_retry_backoff_factor,
backoff_max=client.http_retry_backoff_max,
),
headers=client.headers,
verify=not client.permissive_ssl,
)

return cls(**opts)


class MetadataAPIClient(DWaveAPIClient):
"""Client for D-Wave's Metadata API."""

def __init__(self, **config):
config.setdefault('endpoint', constants.DEFAULT_METADATA_API_ENDPOINT)
super().__init__(**config)
8 changes: 6 additions & 2 deletions dwave/cloud/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@
import enum


# Default SAPI endpoint
DEFAULT_API_ENDPOINT = 'https://cloud.dwavesys.com/sapi/'
# Default API endpoints
DEFAULT_SOLVER_API_ENDPOINT = 'https://cloud.dwavesys.com/sapi/'
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be updated to https://na-west-1.cloud.dwavesys.com/sapi/v2?

Copy link
Member Author

Choose a reason for hiding this comment

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

Both work. We can update the default once MR is GA.

Choose a reason for hiding this comment

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

we will be deprecating the old one at some point - charles is arranging a meeting to discuss timing


DEFAULT_METADATA_API_ENDPOINT = 'https://cloud.dwavesys.com/metadata/v1/'

DEFAULT_REGION = 'na-west-1'


class ProblemStatus(str, enum.Enum):
Expand Down
3 changes: 1 addition & 2 deletions dwave/cloud/api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,7 @@ class ResourceBadResponseError(RequestError):
"""Unexpected resource response"""

class InternalServerError(RequestError):
pass

"""internal server error occurred while request handling."""

class RequestTimeout(RequestError):
"""API request timed out"""
7 changes: 7 additions & 0 deletions dwave/cloud/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,10 @@ class ProblemSubmitError(BatchItemError):

class ProblemCancelError(BatchItemError):
pass


# region info on metadata api
class Region(BaseModel):
code: str
name: str
endpoint: str