Skip to content

Commit b86c1c0

Browse files
devin-ai-integration[bot]blank@buildwithfern.com
andcommitted
Add Area-based Pool logic for regional URL cycling
- Add Area enum (US, EU, AP, CN) for global regions - Add Pool class for managing regional URLs with automatic cycling - Add DNS-based resolver for selecting best available domain (sync and async) - Add next_region() for cycling through region prefixes on failure - Add select_best_domain() for DNS-based domain selection - Support both agora.io and sd-rtn.com domain suffixes - Export domain module from core and main package Based on agora-rest-client-go/agora/domain implementation Co-Authored-By: blank@buildwithfern.com <blank@buildwithfern.com>
1 parent 25ae08a commit b86c1c0

3 files changed

Lines changed: 218 additions & 4 deletions

File tree

src/agoraio/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@
66
from importlib import import_module
77

88
if typing.TYPE_CHECKING:
9-
from . import agents, phone_numbers, telephony
9+
from . import agents, core, phone_numbers, telephony
1010
from .client import Agora, AsyncAgora
11+
from .core.domain import Area, Pool, create_pool
1112
from .version import __version__
1213
_dynamic_imports: typing.Dict[str, str] = {
1314
"Agora": ".client",
15+
"Area": ".core.domain",
1416
"AsyncAgora": ".client",
17+
"Pool": ".core.domain",
1518
"__version__": ".version",
1619
"agents": ".agents",
20+
"core": ".core",
21+
"create_pool": ".core.domain",
1722
"phone_numbers": ".phone_numbers",
1823
"telephony": ".telephony",
1924
}
@@ -40,4 +45,4 @@ def __dir__():
4045
return sorted(lazy_attrs)
4146

4247

43-
__all__ = ["Agora", "AsyncAgora", "__version__", "agents", "phone_numbers", "telephony"]
48+
__all__ = ["Agora", "Area", "AsyncAgora", "Pool", "__version__", "agents", "core", "create_pool", "phone_numbers", "telephony"]

src/agoraio/core/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@
66
from importlib import import_module
77

88
if typing.TYPE_CHECKING:
9+
from .domain import Area, Pool, create_pool
910
from .file import File, with_content_type
10-
_dynamic_imports: typing.Dict[str, str] = {"File": ".file", "with_content_type": ".file"}
11+
_dynamic_imports: typing.Dict[str, str] = {
12+
"Area": ".domain",
13+
"Pool": ".domain",
14+
"create_pool": ".domain",
15+
"File": ".file",
16+
"with_content_type": ".file",
17+
}
1118

1219

1320
def __getattr__(attr_name: str) -> typing.Any:
@@ -31,4 +38,4 @@ def __dir__():
3138
return sorted(lazy_attrs)
3239

3340

34-
__all__ = ["File", "with_content_type"]
41+
__all__ = ["Area", "Pool", "create_pool", "File", "with_content_type"]

src/agoraio/core/domain.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# This file was auto-generated by Fern from our API Definition.
2+
3+
import asyncio
4+
import socket
5+
import threading
6+
import time
7+
from enum import IntEnum
8+
from typing import List, Optional
9+
10+
11+
class Area(IntEnum):
12+
"""Area represents the global regions where the Open API gateway endpoint is located"""
13+
14+
UNKNOWN = 0
15+
US = 1 # US represents the western and eastern regions of the United States
16+
EU = 2 # EU represents the western and central regions of Europe
17+
AP = 3 # AP represents the southeastern and northeastern regions of Asia-Pacific
18+
CN = 4 # CN represents the eastern and northern regions of Chinese mainland
19+
20+
21+
CHINESE_MAINLAND_MAJOR_DOMAIN = "sd-rtn.com"
22+
OVERSEA_MAJOR_DOMAIN = "agora.io"
23+
24+
GLOBAL_DOMAIN_PREFIX = "api"
25+
26+
US_WEST_REGION_DOMAIN_PREFIX = "api-us-west-1"
27+
US_EAST_REGION_DOMAIN_PREFIX = "api-us-east-1"
28+
29+
AP_SOUTHEAST_REGION_DOMAIN_PREFIX = "api-ap-southeast-1"
30+
AP_NORTHEAST_REGION_DOMAIN_PREFIX = "api-ap-northeast-1"
31+
32+
EU_WEST_REGION_DOMAIN_PREFIX = "api-eu-west-1"
33+
EU_CENTRAL_REGION_DOMAIN_PREFIX = "api-eu-central-1"
34+
35+
CN_EAST_REGION_DOMAIN_PREFIX = "api-cn-east-1"
36+
CN_NORTH_REGION_DOMAIN_PREFIX = "api-cn-north-1"
37+
38+
39+
class Domain:
40+
"""Domain contains the regional prefixes and domain suffixes for an area"""
41+
42+
def __init__(self, region_domain_prefixes: List[str], major_domain_suffixes: List[str]):
43+
self.region_domain_prefixes = region_domain_prefixes
44+
self.major_domain_suffixes = major_domain_suffixes
45+
46+
47+
REGION_DOMAIN = {
48+
Area.UNKNOWN: Domain([], []),
49+
Area.US: Domain(
50+
[US_WEST_REGION_DOMAIN_PREFIX, US_EAST_REGION_DOMAIN_PREFIX],
51+
[OVERSEA_MAJOR_DOMAIN, CHINESE_MAINLAND_MAJOR_DOMAIN],
52+
),
53+
Area.EU: Domain(
54+
[EU_WEST_REGION_DOMAIN_PREFIX, EU_CENTRAL_REGION_DOMAIN_PREFIX],
55+
[OVERSEA_MAJOR_DOMAIN, CHINESE_MAINLAND_MAJOR_DOMAIN],
56+
),
57+
Area.AP: Domain(
58+
[AP_SOUTHEAST_REGION_DOMAIN_PREFIX, AP_NORTHEAST_REGION_DOMAIN_PREFIX],
59+
[OVERSEA_MAJOR_DOMAIN, CHINESE_MAINLAND_MAJOR_DOMAIN],
60+
),
61+
Area.CN: Domain(
62+
[CN_EAST_REGION_DOMAIN_PREFIX, CN_NORTH_REGION_DOMAIN_PREFIX],
63+
[CHINESE_MAINLAND_MAJOR_DOMAIN, OVERSEA_MAJOR_DOMAIN],
64+
),
65+
}
66+
67+
68+
class Resolver:
69+
"""Interface for resolving the best domain"""
70+
71+
def resolve(self, domains: List[str], region_prefix: str) -> str:
72+
raise NotImplementedError
73+
74+
75+
class ResolverImpl(Resolver):
76+
"""Default DNS-based resolver implementation"""
77+
78+
def resolve(self, domains: List[str], region_prefix: str) -> str:
79+
result: Optional[str] = None
80+
result_lock = threading.Lock()
81+
82+
def lookup_domain(domain: str) -> None:
83+
nonlocal result
84+
try:
85+
url = f"{region_prefix}.{domain}"
86+
socket.gethostbyname(url)
87+
with result_lock:
88+
if result is None:
89+
result = domain
90+
except socket.gaierror:
91+
pass
92+
93+
threads = []
94+
for domain in domains:
95+
thread = threading.Thread(target=lookup_domain, args=(domain,))
96+
thread.start()
97+
threads.append(thread)
98+
99+
for thread in threads:
100+
thread.join(timeout=5.0)
101+
102+
if result is not None:
103+
return result
104+
105+
raise Exception("query all dns failed")
106+
107+
108+
class AsyncResolverImpl(Resolver):
109+
"""Async DNS-based resolver implementation"""
110+
111+
async def resolve_async(self, domains: List[str], region_prefix: str) -> str:
112+
async def lookup_domain(domain: str) -> str:
113+
url = f"{region_prefix}.{domain}"
114+
loop = asyncio.get_event_loop()
115+
await loop.getaddrinfo(url, None)
116+
return domain
117+
118+
tasks = [lookup_domain(domain) for domain in domains]
119+
120+
for coro in asyncio.as_completed(tasks):
121+
try:
122+
result = await coro
123+
return result
124+
except (socket.gaierror, OSError):
125+
continue
126+
127+
raise Exception("query all dns failed")
128+
129+
130+
UPDATE_DURATION_SECONDS = 30
131+
132+
133+
class Pool:
134+
"""Pool manages a pool of regional URLs with automatic cycling and domain selection"""
135+
136+
def __init__(self, domain_area: Area):
137+
domain_config = REGION_DOMAIN.get(domain_area)
138+
if domain_config is None or len(domain_config.region_domain_prefixes) == 0:
139+
raise ValueError("invalid domain area")
140+
141+
self._domain_area = domain_area
142+
self._domain_suffixes = list(domain_config.major_domain_suffixes)
143+
self._region_prefixes = list(domain_config.region_domain_prefixes)
144+
self._current_region_prefixes = list(self._region_prefixes)
145+
self._current_domain = self._domain_suffixes[0]
146+
self._resolver = ResolverImpl()
147+
self._async_resolver = AsyncResolverImpl()
148+
self._last_update: float = 0
149+
self._lock = threading.Lock()
150+
151+
def _domain_need_update(self) -> bool:
152+
return time.time() - self._last_update > UPDATE_DURATION_SECONDS
153+
154+
def select_best_domain(self) -> None:
155+
"""SelectBestDomain uses DNS resolution to select the best available domain (sync)"""
156+
if not self._domain_need_update():
157+
return
158+
159+
with self._lock:
160+
if self._domain_need_update():
161+
domain = self._resolver.resolve(self._domain_suffixes, self._current_region_prefixes[0])
162+
self._select_domain(domain)
163+
164+
async def select_best_domain_async(self) -> None:
165+
"""SelectBestDomain uses DNS resolution to select the best available domain (async)"""
166+
if not self._domain_need_update():
167+
return
168+
169+
with self._lock:
170+
if self._domain_need_update():
171+
domain = await self._async_resolver.resolve_async(
172+
self._domain_suffixes, self._current_region_prefixes[0]
173+
)
174+
self._select_domain(domain)
175+
176+
def next_region(self) -> None:
177+
"""NextRegion cycles to the next region prefix in the pool"""
178+
with self._lock:
179+
self._current_region_prefixes = self._current_region_prefixes[1:]
180+
if len(self._current_region_prefixes) == 0:
181+
self._current_region_prefixes = list(self._region_prefixes)
182+
183+
def _select_domain(self, domain: str) -> None:
184+
if domain in self._domain_suffixes:
185+
self._current_domain = domain
186+
self._last_update = time.time()
187+
188+
def get_current_url(self) -> str:
189+
"""GetCurrentURL returns the current URL based on the selected region and domain"""
190+
with self._lock:
191+
current_region = self._current_region_prefixes[0]
192+
current_domain = self._current_domain
193+
return f"https://{current_region}.{current_domain}"
194+
195+
def get_area(self) -> Area:
196+
"""Get the current area"""
197+
return self._domain_area
198+
199+
200+
def create_pool(area: Area) -> Pool:
201+
"""Creates a new Pool for the specified area"""
202+
return Pool(area)

0 commit comments

Comments
 (0)