Skip to content

Commit 298efd8

Browse files
devin-ai-integration[bot]blank@buildwithfern.com
andcommitted
Add regional endpoint selection with GatewayArea support
- Add area.py with GatewayArea enum (US, EU, APAC, CN) and RegionalEndpointPool class - Add regional_client.py with RegionalAgora and AsyncRegionalAgora wrapper clients - Update __init__.py to export wrapper clients as Agora/AsyncAgora - Export GatewayArea and RegionalEndpointPool for advanced usage - Add DNS-based domain selection for optimal connectivity - Support region cycling for failover scenarios - Update .fernignore to protect manually added files Co-Authored-By: blank@buildwithfern.com <blank@buildwithfern.com>
1 parent 25ae08a commit 298efd8

4 files changed

Lines changed: 435 additions & 5 deletions

File tree

.fernignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
# Specify files that shouldn't be modified by Fern
2+
src/agoraio/__init__.py
3+
src/agoraio/area.py
4+
src/agoraio/regional_client.py

src/agoraio/__init__.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# This file was auto-generated by Fern from our API Definition.
2+
# Modified to export regional clients as the public API.
23

34
# isort: skip_file
45

@@ -7,17 +8,27 @@
78

89
if typing.TYPE_CHECKING:
910
from . import agents, phone_numbers, telephony
10-
from .client import Agora, AsyncAgora
11+
from .regional_client import RegionalAgora as Agora
12+
from .regional_client import AsyncRegionalAgora as AsyncAgora
13+
from .area import GatewayArea, RegionalEndpointPool
1114
from .version import __version__
15+
1216
_dynamic_imports: typing.Dict[str, str] = {
13-
"Agora": ".client",
14-
"AsyncAgora": ".client",
17+
"Agora": ".regional_client",
18+
"AsyncAgora": ".regional_client",
19+
"GatewayArea": ".area",
20+
"RegionalEndpointPool": ".area",
1521
"__version__": ".version",
1622
"agents": ".agents",
1723
"phone_numbers": ".phone_numbers",
1824
"telephony": ".telephony",
1925
}
2026

27+
_import_aliases: typing.Dict[str, str] = {
28+
"Agora": "RegionalAgora",
29+
"AsyncAgora": "AsyncRegionalAgora",
30+
}
31+
2132

2233
def __getattr__(attr_name: str) -> typing.Any:
2334
module_name = _dynamic_imports.get(attr_name)
@@ -28,7 +39,8 @@ def __getattr__(attr_name: str) -> typing.Any:
2839
if module_name == f".{attr_name}":
2940
return module
3041
else:
31-
return getattr(module, attr_name)
42+
actual_attr = _import_aliases.get(attr_name, attr_name)
43+
return getattr(module, actual_attr)
3244
except ImportError as e:
3345
raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e
3446
except AttributeError as e:
@@ -40,4 +52,13 @@ def __dir__():
4052
return sorted(lazy_attrs)
4153

4254

43-
__all__ = ["Agora", "AsyncAgora", "__version__", "agents", "phone_numbers", "telephony"]
55+
__all__ = [
56+
"Agora",
57+
"AsyncAgora",
58+
"GatewayArea",
59+
"RegionalEndpointPool",
60+
"__version__",
61+
"agents",
62+
"phone_numbers",
63+
"telephony",
64+
]

src/agoraio/area.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""
2+
Regional endpoint management for Agora API.
3+
4+
This module provides area-based URL selection with automatic region cycling
5+
and DNS-based domain selection for optimal connectivity.
6+
"""
7+
8+
import enum
9+
import socket
10+
import threading
11+
import time
12+
import typing
13+
14+
15+
class GatewayArea(enum.Enum):
16+
"""Global regions where the Open API gateway endpoint is located."""
17+
18+
US = "US"
19+
EU = "EU"
20+
APAC = "APAC"
21+
CN = "CN"
22+
23+
24+
CHINESE_MAINLAND_DOMAIN = "sd-rtn.com"
25+
OVERSEAS_DOMAIN = "agora.io"
26+
27+
GLOBAL_DOMAIN_PREFIX = "api"
28+
29+
US_WEST_REGION_PREFIX = "api-us-west-1"
30+
US_EAST_REGION_PREFIX = "api-us-east-1"
31+
32+
AP_SOUTHEAST_REGION_PREFIX = "api-ap-southeast-1"
33+
AP_NORTHEAST_REGION_PREFIX = "api-ap-northeast-1"
34+
35+
EU_WEST_REGION_PREFIX = "api-eu-west-1"
36+
EU_CENTRAL_REGION_PREFIX = "api-eu-central-1"
37+
38+
CN_EAST_REGION_PREFIX = "api-cn-east-1"
39+
CN_NORTH_REGION_PREFIX = "api-cn-north-1"
40+
41+
42+
class _DomainConfig(typing.NamedTuple):
43+
"""Configuration for a gateway area's domains."""
44+
45+
region_prefixes: typing.List[str]
46+
domain_suffixes: typing.List[str]
47+
48+
49+
REGION_DOMAIN_CONFIG: typing.Dict[GatewayArea, _DomainConfig] = {
50+
GatewayArea.US: _DomainConfig(
51+
region_prefixes=[US_WEST_REGION_PREFIX, US_EAST_REGION_PREFIX],
52+
domain_suffixes=[OVERSEAS_DOMAIN, CHINESE_MAINLAND_DOMAIN],
53+
),
54+
GatewayArea.EU: _DomainConfig(
55+
region_prefixes=[EU_WEST_REGION_PREFIX, EU_CENTRAL_REGION_PREFIX],
56+
domain_suffixes=[OVERSEAS_DOMAIN, CHINESE_MAINLAND_DOMAIN],
57+
),
58+
GatewayArea.APAC: _DomainConfig(
59+
region_prefixes=[AP_SOUTHEAST_REGION_PREFIX, AP_NORTHEAST_REGION_PREFIX],
60+
domain_suffixes=[OVERSEAS_DOMAIN, CHINESE_MAINLAND_DOMAIN],
61+
),
62+
GatewayArea.CN: _DomainConfig(
63+
region_prefixes=[CN_EAST_REGION_PREFIX, CN_NORTH_REGION_PREFIX],
64+
domain_suffixes=[CHINESE_MAINLAND_DOMAIN, OVERSEAS_DOMAIN],
65+
),
66+
}
67+
68+
UPDATE_DURATION_SECONDS = 30.0
69+
70+
71+
class RegionalEndpointPool:
72+
"""
73+
Manages a pool of regional URLs with automatic cycling and domain selection.
74+
75+
This class provides:
76+
- Area-based endpoint selection (US, EU, APAC, CN)
77+
- DNS-based domain selection for optimal connectivity
78+
- Region cycling for failover scenarios
79+
"""
80+
81+
def __init__(self, gateway_area: GatewayArea) -> None:
82+
"""
83+
Initialize a regional endpoint pool for the specified area.
84+
85+
Parameters
86+
----------
87+
gateway_area : GatewayArea
88+
The geographic area for endpoint selection.
89+
90+
Raises
91+
------
92+
ValueError
93+
If the gateway_area is not a valid GatewayArea.
94+
"""
95+
if gateway_area not in REGION_DOMAIN_CONFIG:
96+
raise ValueError(f"Invalid gateway area: {gateway_area}")
97+
98+
config = REGION_DOMAIN_CONFIG[gateway_area]
99+
self._gateway_area = gateway_area
100+
self._domain_suffixes = list(config.domain_suffixes)
101+
self._current_domain = self._domain_suffixes[0]
102+
self._region_prefixes = list(config.region_prefixes)
103+
self._current_region_prefixes = list(self._region_prefixes)
104+
self._lock = threading.Lock()
105+
self._last_update: typing.Optional[float] = None
106+
107+
def _domain_needs_update(self) -> bool:
108+
"""Check if the domain selection needs to be refreshed."""
109+
if self._last_update is None:
110+
return True
111+
return (time.time() - self._last_update) > UPDATE_DURATION_SECONDS
112+
113+
def _resolve_domain(self, domains: typing.List[str], region_prefix: str) -> typing.Optional[str]:
114+
"""
115+
Resolve the best available domain using DNS lookup.
116+
117+
Parameters
118+
----------
119+
domains : List[str]
120+
List of domain suffixes to try.
121+
region_prefix : str
122+
The region prefix to use for DNS lookup.
123+
124+
Returns
125+
-------
126+
Optional[str]
127+
The first domain that resolves successfully, or None if all fail.
128+
"""
129+
for domain in domains:
130+
url = f"{region_prefix}.{domain}"
131+
try:
132+
socket.getaddrinfo(url, None)
133+
return domain
134+
except socket.gaierror:
135+
continue
136+
return None
137+
138+
def select_best_domain(self) -> None:
139+
"""
140+
Use DNS resolution to select the best available domain.
141+
142+
This method performs DNS lookups to find the most responsive domain
143+
and updates the current domain selection accordingly.
144+
"""
145+
if not self._domain_needs_update():
146+
return
147+
148+
with self._lock:
149+
if not self._domain_needs_update():
150+
return
151+
152+
domain = self._resolve_domain(
153+
self._domain_suffixes,
154+
self._current_region_prefixes[0],
155+
)
156+
if domain is not None:
157+
self._current_domain = domain
158+
self._last_update = time.time()
159+
160+
def next_region(self) -> None:
161+
"""
162+
Cycle to the next region prefix in the pool.
163+
164+
This method is useful for failover scenarios where the current
165+
region is not responding.
166+
"""
167+
with self._lock:
168+
self._current_region_prefixes = self._current_region_prefixes[1:]
169+
if len(self._current_region_prefixes) == 0:
170+
self._current_region_prefixes = list(self._region_prefixes)
171+
172+
def get_current_url(self) -> str:
173+
"""
174+
Get the current base URL based on the selected region and domain.
175+
176+
Returns
177+
-------
178+
str
179+
The full base URL for API requests.
180+
"""
181+
with self._lock:
182+
current_region = self._current_region_prefixes[0]
183+
current_domain = self._current_domain
184+
return f"https://{current_region}.{current_domain}"
185+
186+
@property
187+
def gateway_area(self) -> GatewayArea:
188+
"""The gateway area this pool is configured for."""
189+
return self._gateway_area

0 commit comments

Comments
 (0)