Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ wrong? [Create an issue and let us know!][issues]*
</a>
</p>

IBind is an unofficial Python API client library for the [Interactive Brokers Client Portal Web API.][ibkr-docs] (recently rebranded to Web API 1.0 or CPAPI 1.0) It supports both REST and WebSocket APIs of the IBKR Web API 1.0. Now fully headless with [OAuth 1.0a][wiki-oauth1a] support.
IBind is an unofficial Python API client library for the [Interactive Brokers Client Portal Web API.][ibkr-docs] (recently rebranded to Web API 1.0 or CPAPI 1.0) It supports both REST and WebSocket APIs of the IBKR Web API 1.0. Now fully headless with support for [OAuth 1.0a][wiki-oauth1a] and OAuth 2.0.

_Note: IBind currently supports only the Web API 1.0 since the [newer Web API][web-api] seems to be still in beta and is not fully documented. Some of its features may work, but it is recommended to use the Web API 1.0's documentation for the time being. Once a complete version of the new Web API is released IBind will be extended to support it._

Expand All @@ -32,7 +32,7 @@ pip install ibind

## Authentication

IBind supports fully headless authentication using [OAuth 1.0a][wiki-oauth1a]. This means no longer needing to run any type software to communicate with IBKR API.
IBind supports fully headless authentication using [OAuth 1.0a][wiki-oauth1a] and OAuth 2.0. This means no longer needing to run any type of software to communicate with IBKR API.

Alternatively, use [IBeam][ibeam] along with this library for easier setup and maintenance of the CP Gateway.

Expand Down
131 changes: 131 additions & 0 deletions examples/rest_09_oauth2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
REST OAuth 2.0.

Showcases usage of OAuth 2.0 with IbkrClient.

This example demonstrates authenticating using OAuth 2.0 and making some basic API calls.

Using IbkrClient with OAuth 2.0 support will automatically handle generating the JWTs
and managing the SSO bearer token. You should be able to use all endpoints in the same
way as when not using OAuth.

IMPORTANT: In order to use OAuth 2.0, you're required to set up the following
environment variables:

- IBIND_USE_OAUTH: Set to True (or pass use_oauth=True to IbkrClient).
- IBIND_OAUTH2_CLIENT_ID: Your OAuth 2.0 Client ID.
- IBIND_OAUTH2_CLIENT_KEY_ID: Your OAuth 2.0 Client Key ID (kid).
- IBIND_OAUTH2_PRIVATE_KEY_PEM: Your OAuth 2.0 private key in PEM format.
To set this as an environment variable, you should replace newlines (\n)
in your PEM file with the literal characters '\\n'.
For example, if your key is:
----BEGIN PRIVATE KEY-----
ABC\nDEF
-----END PRIVATE KEY-----
You would set the environment variable as:
IBIND_OAUTH2_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\\nABC\\nDEF\\n-----END PRIVATE KEY-----"
- IBIND_OAUTH2_USERNAME: Your IBKR username associated with the OAuth 2.0 app.

Optionally, you can also set these if they differ from the defaults:
- IBIND_OAUTH2_TOKEN_URL: Defaults to 'https://api.ibkr.com/oauth2/api/v1/token'.
- IBIND_OAUTH2_SSO_SESSION_URL: Defaults to 'https://api.ibkr.com/gw/api/v1/sso-sessions'.
- IBIND_OAUTH2_AUDIENCE: Defaults to '/token'.
- IBIND_OAUTH2_SCOPE: Defaults to 'sso-sessions.write'.
- IBIND_OAUTH2_REST_URL: Defaults to 'https://api.ibkr.com/v1/api/'.
- IBIND_OAUTH2_IP_ADDRESS: Your public IP address. If not set, the library will attempt to fetch it.

If you prefer setting these variables inline, you can pass an instance of the
OAuth2Config class as the 'oauth_config' parameter to the IbkrClient constructor.
This is especially useful if managing the PEM key via environment variables is cumbersome.

Example of dynamic configuration:
from ibind import IbkrClient, OAuth2Config

oauth_cfg = OAuth2Config(
client_id='your_client_id',
client_key_id='your_client_key_id',
private_key_pem='-----BEGIN PRIVATE KEY-----\nYOUR KEY HERE\n-----END PRIVATE KEY-----', # Direct string
username='your_ibkr_username'
# ... any other overrides
)
client = IbkrClient(use_oauth=True, oauth_config=oauth_cfg)

This example assumes environment variables are set for simplicity.
"""

import os
from ibind import IbkrClient, ibind_logs_initialize
from ibind.oauth.oauth2 import OAuth2Config

# Initialize IBind logs (optional, but good for seeing what's happening)
ibind_logs_initialize()

cacert = os.getenv('IBIND_CACERT', False) # Standard cacert, though OAuth usually implies HTTPS

# --- Essential OAuth 2.0 Environment Variables ---
# These must be set for the example to run using environment-based configuration.
required_env_vars = [
'IBIND_OAUTH2_CLIENT_ID',
'IBIND_OAUTH2_CLIENT_KEY_ID',
'IBIND_OAUTH2_PRIVATE_KEY_PEM',
'IBIND_OAUTH2_USERNAME',
'IBIND_USE_OAUTH' # Ensure this is also set, typically to "True"
]

missing_vars = [var_name for var_name in required_env_vars if not os.getenv(var_name)]

client = None
if missing_vars:
print("Missing required OAuth 2.0 environment variables for this example:")
for var_name in missing_vars:
print(f" - {var_name}")
print("Please set them to run this example, or configure OAuth2Config dynamically (see docstring).")
print("Exiting example.")
else:
if os.getenv('IBIND_USE_OAUTH', '').lower() != 'true':
print("Warning: IBIND_USE_OAUTH is not set to 'True'.")
print(" The client might not attempt OAuth 2.0 as expected by this example.")
# Proceeding anyway, as IbkrClient might still be configured via oauth_config directly below

print("Found required OAuth 2.0 environment variables. Initializing IbkrClient...")
# IbkrClient will use OAuth2Config by default if use_oauth=True and relevant OAuth2 env vars are present.
# Passing an explicit OAuth2Config() ensures it uses OAuth2 and loads from env vars.
try:
client = IbkrClient(cacert=cacert, use_oauth=True, oauth_config=OAuth2Config())
print("IbkrClient initialized for OAuth 2.0.")
except Exception as e:
print(f"Error initializing IbkrClient: {e}")
client = None

if client:
print("\nAttempting a simple API call to confirm OAuth 2.0 authentication...")
try:
tickle_result = client.tickle()
if tickle_result and tickle_result.data and tickle_result.data.get('session') == 'authenticated': # Or other relevant check
print("Tickle successful! OAuth 2.0 authentication appears to be working.")
# Optionally print some part of tickle_result.data for confirmation
# print(f"Tickle data: {tickle_result.data}")
elif tickle_result and tickle_result.data: # Successful call but maybe not the expected auth confirmation
print("Tickle call successful, but session status might not be 'authenticated' or as expected.")
print(f"Tickle data: {tickle_result.data}")
else:
print("Tickle call failed or did not return expected data.")
if tickle_result:
print(f"Tickle result status: {tickle_result.status_code}, Raw response: {tickle_result.raw_response}")
else:
print("Tickle call returned no result object.")

except Exception as e:
print(f"\nAn error occurred during the API call: {e}")
import traceback
traceback.print_exc()
finally:
if hasattr(client, 'close'):
print("\nClosing client session.")
client.close()
else:
# This part is reached if client initialization failed earlier (e.g. missing env vars)
# The message about missing env vars or init error would have already been printed.
pass # No further action needed here, initial error messages suffice

print("\nExample finished.")
94 changes: 49 additions & 45 deletions ibind/base/rest_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,58 +211,62 @@ def _request(
"""
Wrapper function which allows overriding the default request and error handling logic in the subclass.
"""
request_params = filter_none(kwargs)
attempt = 1
while attempt <= self._max_retries:
current_base_url = base_url if base_url is not None else self.base_url
request_url = f'{current_base_url}{endpoint.lstrip("/")}'

base_url = base_url if base_url is not None else self.base_url
all_headers = self._get_headers(method, request_url) # Get base headers from subclass
if extra_headers:
all_headers.update(extra_headers) # Add/override with any specific extra_headers

endpoint = endpoint.lstrip('/')
url = f'{base_url}{endpoint}'

headers = self._get_headers(request_method=method, request_url=url)
headers = {**headers, **(extra_headers or {})}

# we want to allow default values used by IBKR, so we remove all None parameters
kwargs = filter_none(kwargs)

# choose which function should be used to make a reqeust based on use_session field
request_function = self._session.request if self.use_session else requests.request

# we repeat the request attempts in case of ReadTimeouts up to max_retries
for attempt in range(self._max_retries + 1):
if log:
self.logger.info(f'{method} {url} {kwargs}{" (attempt: " + str(attempt) + ")" if attempt > 0 else ""}')

try:
response = request_function(method, url, verify=self.cacert, headers=headers, timeout=self._timeout, **kwargs)
result = Result(request={'url': url, **kwargs})
return self._process_response(response, result)
self.logger.info(f'Attempt {attempt}: {method} {request_url} Headers: {all_headers} Kwargs: {request_params}')

except ReadTimeout as e:
if attempt >= self._max_retries:
raise TimeoutError(f'{self}: Reached max retries ({self._max_retries}) for {method} {url} {kwargs}') from e
result = Result(request={
'method': method,
'url': request_url,
'headers': all_headers,
'params': request_params
})

self.logger.info(f'{self}: Timeout for {method} {url} {kwargs}, retrying attempt {attempt + 1}/{self._max_retries}')
_LOGGER.info(f'{self}: Timeout for {method} {url} {kwargs}, retrying attempt {attempt + 1}/{self._max_retries}')

continue # Continue to the next iteration for a retry

except (ConnectionError, ChunkedEncodingError) as e:
self.logger.warning(
f'{self}: Connection error detected, resetting session and retrying attempt {attempt + 1}/{self._max_retries} :: {str(e)}'
)
_LOGGER.warning(
f'{self}: Connection error detected, resetting session and retrying attempt {attempt + 1}/{self._max_retries} :: {str(e)}'
)
self.close()
try:
if self.use_session:
self.make_session() # Recreate session automatically
continue # Retry the request with a fresh session

except ExternalBrokerError:
raise

response = self._session.request(
method,
request_url,
headers=all_headers,
timeout=self._timeout,
verify=self.cacert,
**request_params
)
else:
response = requests.request(
method,
request_url,
headers=all_headers,
timeout=self._timeout,
verify=self.cacert,
**request_params
)
return self._process_response(response, result)
except (ReadTimeout, Timeout) as e:
self.logger.warning(f'Attempt {attempt} timed out for {method} {request_url}: {e}')
if attempt == self._max_retries:
raise TimeoutError(f'{self}: Max retries reached for {method} {request_url}') from e
attempt += 1
except ChunkedEncodingError as e:
# Treat ChunkedEncodingError similar to a timeout for retry purposes
self.logger.warning(f'Attempt {attempt} failed with ChunkedEncodingError for {method} {request_url}: {e}')
if attempt == self._max_retries:
raise ExternalBrokerError(f'{self}: Max retries reached for {method} {request_url} after ChunkedEncodingError') from e
attempt += 1
except Exception as e:
self.logger.exception(e)
raise ExternalBrokerError(f'{self}: request error: {str(e)}') from e
self.logger.error(f'{self}: Request {method} {request_url} failed: {e}')
raise ExternalBrokerError(f'{self}: Request {method} {request_url} failed') from e
# This line should not be reached if logic is correct, but as a fallback:
raise ExternalBrokerError(f'{self}: Request {method} {request_url} failed after {self._max_retries} retries without specific exception.')
Comment on lines +214 to +269
Copy link
Owner

Choose a reason for hiding this comment

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

I'm struggling to understand this rewrite. Is there any change here that is required for the OAuth 2.0 flow we're introducing? It seems like just replacing one way of doing requests by another way. Some changes you introduce raise some questions:

  • moving url and header generation to within the attempts loop; these stay constant on each attempt and shouldn't need regeneration on each attempt, don't they?
  • adding if attempt == self._max_retries: breaking in ChunkedEncodingError - what's the reasoning behind it?
  • adding Timeout exception on top of ReadTimeout - do we ever run into 'Timeout'?


def _process_response(self, response, result: Result) -> Result:
try:
Expand Down
Loading