revolut-x-python 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- revolut_x/__init__.py +103 -0
- revolut_x/_auth.py +139 -0
- revolut_x/_http.py +270 -0
- revolut_x/_version.py +10 -0
- revolut_x/account.py +203 -0
- revolut_x/client.py +170 -0
- revolut_x/exceptions.py +147 -0
- revolut_x/helpers.py +540 -0
- revolut_x/market.py +469 -0
- revolut_x/orders.py +522 -0
- revolut_x/py.typed +0 -0
- revolut_x/types.py +397 -0
- revolut_x_python-1.0.0.dist-info/METADATA +164 -0
- revolut_x_python-1.0.0.dist-info/RECORD +17 -0
- revolut_x_python-1.0.0.dist-info/WHEEL +5 -0
- revolut_x_python-1.0.0.dist-info/licenses/LICENSE +21 -0
- revolut_x_python-1.0.0.dist-info/top_level.txt +1 -0
revolut_x/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""revolut-x-python — Python SDK for the Revolut X Crypto Exchange REST API.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from revolut_x import RevolutXClient, OrderSide, Interval
|
|
6
|
+
|
|
7
|
+
# Public market data (no API key required)
|
|
8
|
+
client = RevolutXClient()
|
|
9
|
+
ticker = client.get_ticker("BTC-EUR")
|
|
10
|
+
candles = client.get_candles("BTC-EUR", Interval.HOUR_1)
|
|
11
|
+
|
|
12
|
+
# Trading (requires API key + Ed25519 private key)
|
|
13
|
+
client = RevolutXClient(
|
|
14
|
+
api_key="your-api-key",
|
|
15
|
+
private_key_path="keys/private.pem",
|
|
16
|
+
)
|
|
17
|
+
order = client.place_market_order("BTC-EUR", OrderSide.BUY, quote_size="50.00")
|
|
18
|
+
balances = client.get_balances()
|
|
19
|
+
|
|
20
|
+
For full documentation, see https://github.com/janprikryl/revolut-x-python
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
from revolut_x._version import __version__
|
|
25
|
+
from revolut_x.client import RevolutXClient
|
|
26
|
+
from revolut_x.types import (
|
|
27
|
+
OrderSide,
|
|
28
|
+
OrderType,
|
|
29
|
+
Interval,
|
|
30
|
+
TimeInForce,
|
|
31
|
+
# TypedDicts
|
|
32
|
+
Balance,
|
|
33
|
+
Candle,
|
|
34
|
+
CurrencyConfig,
|
|
35
|
+
Fill,
|
|
36
|
+
OrderBook,
|
|
37
|
+
OrderBookLevel,
|
|
38
|
+
OrderDetail,
|
|
39
|
+
OrderResponse,
|
|
40
|
+
PairConfig,
|
|
41
|
+
Ticker,
|
|
42
|
+
Trade,
|
|
43
|
+
)
|
|
44
|
+
from revolut_x.helpers import (
|
|
45
|
+
FeeCalculator,
|
|
46
|
+
FeeEstimate,
|
|
47
|
+
OrderPayloadBuilder,
|
|
48
|
+
build_limit_order,
|
|
49
|
+
build_maker_order,
|
|
50
|
+
build_market_order,
|
|
51
|
+
build_order,
|
|
52
|
+
calculate_maker_price,
|
|
53
|
+
normalize_symbol,
|
|
54
|
+
)
|
|
55
|
+
from revolut_x.exceptions import (
|
|
56
|
+
ApiError,
|
|
57
|
+
AuthenticationError,
|
|
58
|
+
NetworkError,
|
|
59
|
+
OrderValidationError,
|
|
60
|
+
RateLimitError,
|
|
61
|
+
RevolutXError,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
# Metadata
|
|
66
|
+
"__version__",
|
|
67
|
+
# Client
|
|
68
|
+
"RevolutXClient",
|
|
69
|
+
# Enums
|
|
70
|
+
"OrderSide",
|
|
71
|
+
"OrderType",
|
|
72
|
+
"Interval",
|
|
73
|
+
"TimeInForce",
|
|
74
|
+
# Helpers
|
|
75
|
+
"OrderPayloadBuilder",
|
|
76
|
+
"FeeCalculator",
|
|
77
|
+
"FeeEstimate",
|
|
78
|
+
"normalize_symbol",
|
|
79
|
+
"calculate_maker_price",
|
|
80
|
+
"build_order",
|
|
81
|
+
"build_market_order",
|
|
82
|
+
"build_limit_order",
|
|
83
|
+
"build_maker_order",
|
|
84
|
+
# Type definitions
|
|
85
|
+
"Balance",
|
|
86
|
+
"Candle",
|
|
87
|
+
"CurrencyConfig",
|
|
88
|
+
"Fill",
|
|
89
|
+
"OrderBook",
|
|
90
|
+
"OrderBookLevel",
|
|
91
|
+
"OrderDetail",
|
|
92
|
+
"OrderResponse",
|
|
93
|
+
"PairConfig",
|
|
94
|
+
"Ticker",
|
|
95
|
+
"Trade",
|
|
96
|
+
# Exceptions
|
|
97
|
+
"RevolutXError",
|
|
98
|
+
"AuthenticationError",
|
|
99
|
+
"RateLimitError",
|
|
100
|
+
"ApiError",
|
|
101
|
+
"OrderValidationError",
|
|
102
|
+
"NetworkError",
|
|
103
|
+
]
|
revolut_x/_auth.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Internal module for Ed25519 authentication for Revolut X."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
from cryptography.hazmat.primitives import serialization
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
12
|
+
from revolut_x.exceptions import AuthenticationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_private_key(source: str | Path | bytes) -> Ed25519PrivateKey:
|
|
16
|
+
"""Load an Ed25519 private key from a file or raw PEM bytes.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
source: The path to the PEM file (str or Path),
|
|
20
|
+
or the raw PEM data (bytes).
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Ed25519PrivateKey: The loaded Ed25519 private key.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
AuthenticationError: If the file is not found, or the key format is invalid.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
if isinstance(source, bytes):
|
|
30
|
+
pem_data = source
|
|
31
|
+
else:
|
|
32
|
+
path = Path(source)
|
|
33
|
+
if not path.is_file():
|
|
34
|
+
raise AuthenticationError(f"Private key file not found at: {path}")
|
|
35
|
+
try:
|
|
36
|
+
pem_data = path.read_bytes()
|
|
37
|
+
except OSError as e:
|
|
38
|
+
raise AuthenticationError(f"Failed to read private key file at {path}: {e}")
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
private_key = serialization.load_pem_private_key(pem_data, password=None)
|
|
42
|
+
if not isinstance(private_key, Ed25519PrivateKey):
|
|
43
|
+
raise AuthenticationError("The provided key is not an Ed25519 private key.")
|
|
44
|
+
return private_key
|
|
45
|
+
except ValueError as e:
|
|
46
|
+
raise AuthenticationError(f"Invalid private key format: {e}")
|
|
47
|
+
except Exception as e:
|
|
48
|
+
raise AuthenticationError(f"Failed to load private key: {e}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_signature_message(
|
|
52
|
+
timestamp_ms: str,
|
|
53
|
+
method: str,
|
|
54
|
+
path: str,
|
|
55
|
+
params: dict[str, Any] | None = None,
|
|
56
|
+
body: Any | None = None
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Build the exact signature message string for Revolut X.
|
|
59
|
+
|
|
60
|
+
The format is: {timestamp}{METHOD}{path}{query_string}{body}.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
timestamp_ms: The current timestamp in milliseconds.
|
|
64
|
+
method: The HTTP method (e.g., 'GET', 'POST').
|
|
65
|
+
path: The request path, which must start with '/api'.
|
|
66
|
+
params: Query parameters to include.
|
|
67
|
+
body: The request body. Can be a dict/list (will be minified JSON)
|
|
68
|
+
or a raw string.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
str: The constructed message string to be signed.
|
|
72
|
+
"""
|
|
73
|
+
method_upper = method.upper()
|
|
74
|
+
|
|
75
|
+
normalized_path = path if path.startswith("/api") else f"/api{path}"
|
|
76
|
+
|
|
77
|
+
query_str = ""
|
|
78
|
+
if params:
|
|
79
|
+
clean_params = [(k, str(v)) for k, v in sorted(params.items()) if v is not None]
|
|
80
|
+
query_str = urlencode(clean_params)
|
|
81
|
+
|
|
82
|
+
body_str = ""
|
|
83
|
+
if body is not None:
|
|
84
|
+
if isinstance(body, (dict, list)):
|
|
85
|
+
body_str = json.dumps(body, separators=(",", ":"))
|
|
86
|
+
elif isinstance(body, str):
|
|
87
|
+
body_str = body
|
|
88
|
+
|
|
89
|
+
return f"{timestamp_ms}{method_upper}{normalized_path}{query_str}{body_str}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def sign_request(
|
|
93
|
+
api_key: str,
|
|
94
|
+
private_key: Ed25519PrivateKey,
|
|
95
|
+
method: str,
|
|
96
|
+
path: str,
|
|
97
|
+
params: dict[str, Any] | None = None,
|
|
98
|
+
body: Any | None = None,
|
|
99
|
+
timestamp_offset_ms: int = 0,
|
|
100
|
+
) -> dict[str, str]:
|
|
101
|
+
"""Sign a request and return the necessary authentication headers.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
api_key: The Revolut X API key.
|
|
105
|
+
private_key: The loaded Ed25519 private key.
|
|
106
|
+
method: The HTTP method.
|
|
107
|
+
path: The request path.
|
|
108
|
+
params: The query parameters.
|
|
109
|
+
body: The request body.
|
|
110
|
+
timestamp_offset_ms: Optional offset in milliseconds to adjust local clock.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
A dictionary containing the authentication headers
|
|
114
|
+
(X-Revx-API-Key, X-Revx-Timestamp, X-Revx-Signature) and Content-Type
|
|
115
|
+
if a body is provided.
|
|
116
|
+
"""
|
|
117
|
+
timestamp_ms = str(int(time.time() * 1000) + int(timestamp_offset_ms))
|
|
118
|
+
|
|
119
|
+
message = build_signature_message(
|
|
120
|
+
timestamp_ms=timestamp_ms,
|
|
121
|
+
method=method,
|
|
122
|
+
path=path,
|
|
123
|
+
params=params,
|
|
124
|
+
body=body
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
signature_bytes = private_key.sign(message.encode("utf-8"))
|
|
128
|
+
signature_b64 = base64.b64encode(signature_bytes).decode("utf-8")
|
|
129
|
+
|
|
130
|
+
headers = {
|
|
131
|
+
"X-Revx-API-Key": api_key,
|
|
132
|
+
"X-Revx-Timestamp": timestamp_ms,
|
|
133
|
+
"X-Revx-Signature": signature_b64
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if body is not None:
|
|
137
|
+
headers["Content-Type"] = "application/json"
|
|
138
|
+
|
|
139
|
+
return headers
|
revolut_x/_http.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Internal HTTP client module for Revolut X REST API requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import email.utils
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
import requests
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
11
|
+
from revolut_x._auth import sign_request
|
|
12
|
+
from revolut_x._version import __version__
|
|
13
|
+
from revolut_x.exceptions import ApiError, AuthenticationError, NetworkError, RateLimitError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
#: Default User-Agent sent to the exchange. Derived from the package version so
|
|
18
|
+
#: it cannot drift out of sync with the actual release.
|
|
19
|
+
DEFAULT_USER_AGENT = f"revolut-x-python/{__version__}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HttpClient:
|
|
23
|
+
"""HTTP client handling authentication, headers, and rate limiting for Revolut X.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
base_url (str): The base URL for the API.
|
|
27
|
+
api_version (str): The API version string.
|
|
28
|
+
api_key (str | None): The API key for authenticated requests.
|
|
29
|
+
private_key (Ed25519PrivateKey | None): The private key for signing requests.
|
|
30
|
+
request_delay (float): Minimum seconds between outgoing requests.
|
|
31
|
+
timeout (int): Timeout in seconds for HTTP requests.
|
|
32
|
+
max_retries (int): Maximum number of retry attempts for 429 and network errors.
|
|
33
|
+
user_agent (str): User agent string for the requests.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
base_url: str = "https://revx.revolut.com/api",
|
|
40
|
+
api_version: str = "1.0",
|
|
41
|
+
api_key: str | None = None,
|
|
42
|
+
private_key: Ed25519PrivateKey | None = None,
|
|
43
|
+
request_delay: float = 0.85,
|
|
44
|
+
timeout: int = 15,
|
|
45
|
+
max_retries: int = 3,
|
|
46
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
47
|
+
timestamp_offset_ms: int = 0,
|
|
48
|
+
):
|
|
49
|
+
"""Initialize the HTTP client.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
base_url (str): The base URL for the API.
|
|
53
|
+
api_version (str): The API version string.
|
|
54
|
+
api_key (str | None): The API key for authenticated requests.
|
|
55
|
+
private_key (Ed25519PrivateKey | None): The private key for signing requests.
|
|
56
|
+
request_delay (float): Minimum seconds between outgoing requests.
|
|
57
|
+
timeout (int): Timeout in seconds for HTTP requests.
|
|
58
|
+
max_retries (int): Maximum number of retry attempts for 429 and network errors.
|
|
59
|
+
user_agent (str): User agent string for the requests.
|
|
60
|
+
timestamp_offset_ms (int): Clock offset in milliseconds for request signing.
|
|
61
|
+
"""
|
|
62
|
+
self.base_url = base_url.rstrip("/")
|
|
63
|
+
self.api_version = api_version
|
|
64
|
+
self.api_key = api_key
|
|
65
|
+
self.private_key = private_key
|
|
66
|
+
self.request_delay = request_delay
|
|
67
|
+
self.timeout = timeout
|
|
68
|
+
self.max_retries = max_retries
|
|
69
|
+
self.timestamp_offset_ms = timestamp_offset_ms
|
|
70
|
+
self._time_synced = False
|
|
71
|
+
|
|
72
|
+
self.session = requests.Session()
|
|
73
|
+
self.session.headers.update({"User-Agent": user_agent})
|
|
74
|
+
self._last_request_time = 0.0
|
|
75
|
+
|
|
76
|
+
def sync_time(self) -> int:
|
|
77
|
+
"""Synchronize timestamp offset with Revolut X server time using the Date response header."""
|
|
78
|
+
try:
|
|
79
|
+
resp = self.session.head(
|
|
80
|
+
f"{self.base_url}/{self.api_version}/public/configuration/currencies",
|
|
81
|
+
timeout=self.timeout,
|
|
82
|
+
)
|
|
83
|
+
date_header = resp.headers.get("Date")
|
|
84
|
+
if date_header:
|
|
85
|
+
parsed = email.utils.parsedate_to_datetime(date_header)
|
|
86
|
+
server_ms = int(parsed.timestamp() * 1000)
|
|
87
|
+
local_ms = int(time.time() * 1000)
|
|
88
|
+
self.timestamp_offset_ms = server_ms - local_ms - 500
|
|
89
|
+
logger.debug("Synchronized time offset: %d ms", self.timestamp_offset_ms)
|
|
90
|
+
return self.timestamp_offset_ms
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
logger.warning("Could not sync server time: %s", exc)
|
|
93
|
+
return self.timestamp_offset_ms
|
|
94
|
+
|
|
95
|
+
def _rate_limit_wait(self) -> None:
|
|
96
|
+
"""Enforce spacing between outgoing requests to avoid 429 Rate Limits."""
|
|
97
|
+
elapsed = time.time() - self._last_request_time
|
|
98
|
+
if elapsed < self.request_delay:
|
|
99
|
+
time.sleep(self.request_delay - elapsed)
|
|
100
|
+
self._last_request_time = time.time()
|
|
101
|
+
|
|
102
|
+
def request(
|
|
103
|
+
self,
|
|
104
|
+
method: str,
|
|
105
|
+
endpoint: str,
|
|
106
|
+
*,
|
|
107
|
+
params: dict[str, Any] | None = None,
|
|
108
|
+
json_body: Any | None = None,
|
|
109
|
+
authenticated: bool = False,
|
|
110
|
+
) -> tuple[int, Any]:
|
|
111
|
+
"""Execute an HTTP request against the Revolut X API.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
method (str): The HTTP method (GET, POST, etc.).
|
|
115
|
+
endpoint (str): The API endpoint path (e.g., '/orders').
|
|
116
|
+
params (dict[str, Any] | None): Optional query parameters.
|
|
117
|
+
json_body (Any | None): Optional JSON body data.
|
|
118
|
+
authenticated (bool): Whether the request requires authentication.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
tuple[int, Any]: A tuple containing the HTTP status code and the parsed response body
|
|
122
|
+
(as a dict or list), or text if not parseable.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
AuthenticationError: If authentication is required but missing, or on 401/403.
|
|
126
|
+
RateLimitError: If the maximum rate limit retries are exhausted (429).
|
|
127
|
+
ApiError: For other 4xx or 5xx status codes.
|
|
128
|
+
NetworkError: On fundamental network failures (e.g., connection errors).
|
|
129
|
+
"""
|
|
130
|
+
if not endpoint.startswith("/"):
|
|
131
|
+
endpoint = f"/{endpoint}"
|
|
132
|
+
|
|
133
|
+
# The canonical signature message uses alphabetically sorted query
|
|
134
|
+
# params (see docs/concepts/auth_ed25519.md), while requests would
|
|
135
|
+
# serialise them in insertion order. Sort here so the signed string and
|
|
136
|
+
# the string actually put on the wire are identical either way. None
|
|
137
|
+
# values are dropped for the same reason — the signer skips them too.
|
|
138
|
+
if params:
|
|
139
|
+
params = {k: v for k, v in sorted(params.items()) if v is not None}
|
|
140
|
+
|
|
141
|
+
api_path = f"/api/{self.api_version}{endpoint}"
|
|
142
|
+
full_url = f"{self.base_url}/{self.api_version}{endpoint}"
|
|
143
|
+
|
|
144
|
+
if authenticated and not getattr(self, "_time_synced", False) and self.timestamp_offset_ms == 0:
|
|
145
|
+
self._time_synced = True
|
|
146
|
+
self.sync_time()
|
|
147
|
+
|
|
148
|
+
headers: dict[str, str] = {}
|
|
149
|
+
if authenticated:
|
|
150
|
+
if not self.api_key or not self.private_key:
|
|
151
|
+
raise AuthenticationError(
|
|
152
|
+
f"Authentication required for {endpoint}, but api_key or private_key is not configured. "
|
|
153
|
+
"Please initialize the client with both api_key and private_key."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
body_kwargs: dict[str, Any] = {}
|
|
157
|
+
if json_body is not None:
|
|
158
|
+
body_kwargs["data"] = json.dumps(json_body, separators=(",", ":"))
|
|
159
|
+
headers["Content-Type"] = "application/json"
|
|
160
|
+
|
|
161
|
+
wait_sec: float | None = None
|
|
162
|
+
for attempt in range(1, self.max_retries + 1):
|
|
163
|
+
if authenticated:
|
|
164
|
+
auth_headers = sign_request(
|
|
165
|
+
api_key=self.api_key,
|
|
166
|
+
private_key=self.private_key,
|
|
167
|
+
method=method,
|
|
168
|
+
path=api_path,
|
|
169
|
+
params=params,
|
|
170
|
+
body=json_body,
|
|
171
|
+
timestamp_offset_ms=self.timestamp_offset_ms,
|
|
172
|
+
)
|
|
173
|
+
headers.update(auth_headers)
|
|
174
|
+
|
|
175
|
+
self._rate_limit_wait()
|
|
176
|
+
try:
|
|
177
|
+
response = self.session.request(
|
|
178
|
+
method=method.upper(),
|
|
179
|
+
url=full_url,
|
|
180
|
+
params=params,
|
|
181
|
+
headers=headers,
|
|
182
|
+
timeout=self.timeout,
|
|
183
|
+
**body_kwargs
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
status = response.status_code
|
|
187
|
+
|
|
188
|
+
# Check response Date header for background drift correction
|
|
189
|
+
date_hdr = response.headers.get("Date")
|
|
190
|
+
if date_hdr:
|
|
191
|
+
try:
|
|
192
|
+
p = email.utils.parsedate_to_datetime(date_hdr)
|
|
193
|
+
diff = int(p.timestamp() * 1000) - int(time.time() * 1000)
|
|
194
|
+
if abs(diff - self.timestamp_offset_ms) > 3000:
|
|
195
|
+
self.timestamp_offset_ms = diff - 500
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
# Handle Rate Limiting (429)
|
|
200
|
+
if status == 429:
|
|
201
|
+
if attempt == self.max_retries:
|
|
202
|
+
raise RateLimitError(
|
|
203
|
+
f"Rate limit exceeded for {method.upper()} {endpoint} after {self.max_retries} retries.",
|
|
204
|
+
retry_after_seconds=wait_sec,
|
|
205
|
+
endpoint=endpoint,
|
|
206
|
+
)
|
|
207
|
+
retry_after_str = response.headers.get("Retry-After", "2000")
|
|
208
|
+
try:
|
|
209
|
+
retry_after_ms = float(retry_after_str)
|
|
210
|
+
wait_sec = max(retry_after_ms / 1000.0, 1.0)
|
|
211
|
+
except ValueError:
|
|
212
|
+
wait_sec = 2.0
|
|
213
|
+
|
|
214
|
+
wait_sec *= attempt
|
|
215
|
+
logger.warning(
|
|
216
|
+
"Rate limited on %s. Waiting %.2fs (attempt %d/%d).",
|
|
217
|
+
endpoint, wait_sec, attempt, self.max_retries
|
|
218
|
+
)
|
|
219
|
+
time.sleep(wait_sec)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
data = response.json()
|
|
224
|
+
except Exception:
|
|
225
|
+
data = response.text
|
|
226
|
+
|
|
227
|
+
# Handle timestamp drift error (409 Request timestamp is in the future)
|
|
228
|
+
if status == 409 and isinstance(data, dict):
|
|
229
|
+
msg = str(data.get("message", "")).lower()
|
|
230
|
+
if "future" in msg or "timestamp" in data:
|
|
231
|
+
server_ts = int(data.get("timestamp") or 0)
|
|
232
|
+
if server_ts:
|
|
233
|
+
self.timestamp_offset_ms = server_ts - int(time.time() * 1000) - 1000
|
|
234
|
+
else:
|
|
235
|
+
self.sync_time()
|
|
236
|
+
logger.warning(
|
|
237
|
+
"Timestamp clock drift detected on %s (HTTP 409). Adjusted offset to %d ms. Retrying...",
|
|
238
|
+
endpoint, self.timestamp_offset_ms
|
|
239
|
+
)
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# Handle other status codes
|
|
243
|
+
if status in (401, 403):
|
|
244
|
+
raise AuthenticationError(
|
|
245
|
+
f"Authentication failed for {method.upper()} {endpoint} (HTTP {status}): {data}",
|
|
246
|
+
hint="Check that your API key is valid and the private key matches the registered public key.",
|
|
247
|
+
)
|
|
248
|
+
elif not (200 <= status < 300):
|
|
249
|
+
raise ApiError(
|
|
250
|
+
f"Revolut X API returned HTTP {status} for {method.upper()} {endpoint}: {data}",
|
|
251
|
+
status_code=status,
|
|
252
|
+
response_body=data,
|
|
253
|
+
endpoint=endpoint,
|
|
254
|
+
method=method.upper(),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return status, data
|
|
258
|
+
|
|
259
|
+
except requests.RequestException as e:
|
|
260
|
+
logger.error("Network error on %s %s: %s", method, full_url, e)
|
|
261
|
+
if attempt == self.max_retries:
|
|
262
|
+
raise NetworkError(
|
|
263
|
+
f"Network error calling {method.upper()} {endpoint} after {self.max_retries} attempts: {e}",
|
|
264
|
+
original_error=e,
|
|
265
|
+
) from e
|
|
266
|
+
|
|
267
|
+
# Exponential backoff on network error
|
|
268
|
+
time.sleep(1.0 * (2 ** (attempt - 1)))
|
|
269
|
+
|
|
270
|
+
raise RateLimitError(f"Maximum retries ({self.max_retries}) exhausted for {endpoint}.")
|
revolut_x/_version.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Single source of truth for the package version.
|
|
2
|
+
|
|
3
|
+
Both ``revolut_x.__version__`` and the default ``User-Agent`` in
|
|
4
|
+
:mod:`revolut_x._http` read from here, and ``pyproject.toml`` resolves its
|
|
5
|
+
``version`` field to this attribute. Bump it in this one place only — the
|
|
6
|
+
three used to be maintained by hand and had drifted apart (0.0.6 / 0.0.8 /
|
|
7
|
+
0.1.0 simultaneously).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "1.0.0"
|