Skip to content

Commit 864ae0d

Browse files
Fix suffix before baking endpoints (#41)
* fix(client): Fix #40: Prevent version suffix duplication in api_url during f-string concat * build: Exclude tests from sdist * docs(readme): Update usage examples and address the issue #40 * docs: Update changelog * style: Add type hints
1 parent 34a3e6d commit 864ae0d

6 files changed

Lines changed: 158 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ We [keep a changelog.](http://keepachangelog.com/)
44

55
## [Unreleased]
66

7+
### Fixed
8+
9+
- **Config**: Fixed a URL routing regression where explicitly passing a version suffix (like `/v3`) in the `api_url` caused duplicate version paths (`/v3/v3`) resulting in 404s (#40).
10+
- Fixed the usage's example in `README.md` of the `api_url` parameter that must strictly be the **base host only**.
11+
712
## [1.7.0] - 2026-05-01
813

914
### Added

README.md

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,9 @@ The Mailgun API is part of the Sinch family and enables you to send, track, and
210210
### Base URL
211211

212212
All API calls referenced in our documentation start with a base URL. Mailgun allows the ability to send and receive
213-
email in both US and EU regions. Be sure to use the appropriate base URL based on which region you have created for your
214-
domain.
213+
email in both US and EU regions.
215214

216-
It is also important to note that Mailgun uses URI versioning for our API endpoints, and some endpoints may have
217-
different versions than others. Please reference the version stated in the URL for each endpoint.
215+
If you are using a proxy or a regional endpoint (such as the EU infrastructure), you can configure a custom `api_url` during initialization.
218216

219217
For domains created in our US region the base URL is:
220218

@@ -228,11 +226,16 @@ For domains created in our EU region the base URL is:
228226
https://api.eu.mailgun.net/
229227
```
230228

231-
Your Mailgun account may contain multiple sending domains. To avoid passing the domain name as a query parameter, most
232-
API URLs must include the name of the domain you are interested in:
229+
**⚠️ Important:** The `api_url` parameter must strictly be the **base host only** (e.g., `https://api.eu.mailgun.net`). Do **not** append API version paths (like `/v3` or `/v4`) to this string. The SDK's data-driven routing engine automatically appends the correct, endpoint-specific API version under the hood.
233230

234-
```sh
235-
https://api.mailgun.net/v3/mydomain.com
231+
```python
232+
import os
233+
from mailgun.client import Client
234+
235+
# Pass ONLY the base domain
236+
with Client(auth=("api", os.environ["APIKEY"]), api_url="https://api.eu.mailgun.net") as client:
237+
# do someshings
238+
pass
236239
```
237240

238241
### Authentication
@@ -266,26 +269,21 @@ Synchronous vs Asynchronous Client.
266269

267270
### Client
268271

269-
Initialize your [Mailgun](http://www.mailgun.com/) client:
270-
271-
```python
272-
from mailgun.client import Client
273-
import os
274-
275-
auth = ("api", os.environ["APIKEY"])
276-
client = Client(auth=auth)
277-
```
278-
279272
#### Client Lifecycle & Resource Management
280273

274+
Initialize your [Mailgun](http://www.mailgun.com/) client.
275+
281276
> [!TIP]
282277
> **New in v1.7.0:** The SDK now utilizes connection pooling (`requests.Session`) under the hood to dramatically improve performance by reusing TLS connections.
283278
284279
**The Simple Variant (Backward Compatible)**
285280
For simple scripts, lambdas, or single-request apps, you can initialize and use the client directly. Python's garbage collector will eventually clean up the connection.
286281

287282
```python
288-
client = Client(auth=("api", "KEY"))
283+
import os
284+
from mailgun.client import Client
285+
286+
client = Client(auth=("api", os.environ["APIKEY"]))
289287
client.messages.create(data={"to": "user@example.com"})
290288
```
291289

@@ -298,8 +296,11 @@ If you are running long-lived applications (like Celery workers, web servers, or
298296
For production applications, \**always use the client as a Context Manager* (`with`) or explicitly call `client.close()`. This ensures deterministic release of TCP connection pools.
299297

300298
```python
299+
import os
300+
from mailgun.client import Client
301+
301302
# Sockets are safely managed and closed automatically
302-
with Client(auth=("api", "KEY")) as client:
303+
with Client(auth=("api", os.environ["APIKEY"])) as client:
303304
client.messages.create(data={"to": "user@example.com"})
304305
```
305306

@@ -308,7 +309,7 @@ with Client(auth=("api", "KEY")) as client:
308309
By default, the SDK routes traffic to the US servers (`https://api.mailgun.net`). If you are operating in the EU, you can override the base URL during initialization:
309310

310311
```python
311-
client = Client(auth=("api", "KEY"), api_url="https://api.eu.mailgun.net")
312+
client = Client(auth=("api", os.environ["APIKEY"]), api_url="https://api.eu.mailgun.net")
312313
```
313314

314315
The SDK also implements Timeouts by default `read=60.0s` (but can take a tuple with connect/read `(10.0, 60.0)` to ensure your application fails-fast during network partitions but remains patient while Mailgun processes heavy analytical queries).
@@ -322,8 +323,6 @@ import asyncio
322323
import os
323324
from mailgun.client import AsyncClient
324325

325-
auth = ("api", os.environ["APIKEY"])
326-
327326

328327
async def main():
329328
# BEST PRACTICE: Use the async context manager for safe connection pooling

mailgun/client.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -513,13 +513,59 @@ def __init__(self, api_url: str | None = None) -> None:
513513
"""
514514
self.ex_handler: bool = True
515515
base_url_input: str = api_url or self.DEFAULT_API_URL
516-
self.api_url: str = SecurityGuard.sanitize_api_url(base_url_input)
517516

518-
# PRE-BAKE: Cache base URLs for all versions at once
517+
self.api_url: str = self._normalize_api_url(base_url_input)
518+
519519
self._baked_urls: Final[dict[str, str]] = {
520520
ver.value: f"{self.api_url}/{ver.value}" for ver in APIVersion
521521
}
522522

523+
@staticmethod
524+
def _normalize_api_url(raw_url: str) -> str:
525+
"""Validates and normalizes the base API URL.
526+
527+
Ensures no explicit versions are embedded in the path that would break
528+
dynamic f-string routing.
529+
530+
Args:
531+
raw_url: The raw base URL string provided by the user.
532+
533+
Returns:
534+
The sanitized and normalized API URL string.
535+
536+
Raises:
537+
ApiError: If an ambiguous API version is found embedded within the custom path.
538+
"""
539+
safe_url: str = SecurityGuard.sanitize_api_url(raw_url)
540+
541+
parsed = urlparse(safe_url)
542+
path_segments = [seg for seg in parsed.path.split("/") if seg]
543+
544+
known_versions = {ver.value for ver in APIVersion}
545+
546+
# Ambiguity & Backward Compatibility Check
547+
for i, segment in enumerate(path_segments):
548+
if segment in known_versions:
549+
is_last_segment = i == len(path_segments) - 1
550+
551+
if is_last_segment:
552+
safe_url = safe_url.removesuffix(f"/{segment}")
553+
logger.warning(
554+
"Semantic Configuration Warning: 'api_url' should be the base domain. The trailing '%s' was stripped to prevent routing duplication.",
555+
segment,
556+
)
557+
else:
558+
# Fail-Fast: The version is trapped inside a complex path
559+
msg = (
560+
f"Ambiguous API URL configuration: '{raw_url}'.\n"
561+
f"The SDK automatically handles version routing, but an explicit "
562+
f"version ('{segment}') was found embedded within your custom path. "
563+
f"Please provide only the base host (e.g., 'https://api.mailgun.net')."
564+
)
565+
raise ApiError(msg)
566+
567+
return safe_url
568+
523569
def _build_base_url(self, version: APIVersion | str, suffix: str = "") -> str:
524570
"""Construct API URL with precise slash control to prevent 404s.
525571

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ urls."Repository" = "https://github.com/mailgun/mailgun-python"
100100
py-modules = [ "mailgun._version" ]
101101

102102
[tool.setuptools.packages.find]
103-
include = [ "mailgun", "mailgun.handlers", "mailgun.*", "tests", "tests.*" ]
103+
include = [ "mailgun", "mailgun.handlers", "mailgun.*" ]
104+
exclude = [ "tests", "tests.*" ]
104105

105106
[tool.setuptools.package-data]
106107
mailgun = [ "py.typed", "*.pyi" ]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import pytest
2+
from mailgun.client import Config
3+
4+
@pytest.mark.parametrize(
5+
"api_url",
6+
[
7+
"https://api.eu.mailgun.net/v3",
8+
"https://api.eu.mailgun.net/v3/",
9+
"https://api.eu.mailgun.net/v4",
10+
"https://api.eu.mailgun.net/v4/",
11+
],
12+
ids=["v3_without_trailing_slash",
13+
"v3_with_trailing_slash",
14+
"v4_without_trailing_slash",
15+
"v4_with_trailing_slash",
16+
]
17+
)
18+
def test_api_url_with_trailing_version(api_url: str) -> None:
19+
"""
20+
Regression test for #40: v1.7.0 silently broke api_url values containing /v3.
21+
Tests that an explicitly passed version segment does not result in duplication.
22+
"""
23+
config = Config(api_url=api_url)
24+
25+
# Before the fix, this evaluated to 'https://api.eu.mailgun.net/v3/v3' and failed.
26+
if "mailgun" in api_url:
27+
assert config._baked_urls["v3"] == "https://api.eu.mailgun.net/v3"
28+
assert config._baked_urls["v4"] == "https://api.eu.mailgun.net/v4"
29+
30+
31+
def test_api_url_emits_semantic_warning_on_version_suffix(caplog: pytest.LogCaptureFixture) -> None:
32+
import logging
33+
34+
with caplog.at_level(logging.WARNING):
35+
config = Config(api_url="https://api.eu.mailgun.net/v3/")
36+
37+
assert config._baked_urls["v3"] == "https://api.eu.mailgun.net/v3"
38+
assert "Semantic Configuration Warning" in caplog.text
39+
assert "should be the base domain" in caplog.text

tests/unit/test_config.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44
from unittest.mock import MagicMock, patch
55

6+
from mailgun import ApiError
67
from mailgun.client import Config
78
from mailgun.client import SecurityGuard
89

@@ -211,3 +212,44 @@ def test_build_base_url_prevents_double_slash(self) -> None:
211212
assert result_no_suffix == "https://api.mailgun.net/v3/"
212213
# The critical check: ensure no double slashes were formed
213214
assert "//domains" not in result_with_suffix
215+
216+
def test_normalize_api_url_clean_url(self) -> None:
217+
"""Verify that a clean base URL passes through without modification."""
218+
clean_url = "https://api.mailgun.net"
219+
result = Config._normalize_api_url(clean_url)
220+
221+
assert result == "https://api.mailgun.net"
222+
223+
@patch("mailgun.client.logger.warning")
224+
def test_normalize_api_url_strips_trailing_version(self, mock_warn: MagicMock) -> None:
225+
"""
226+
Verify the backward compatibility branch:
227+
A trailing version is stripped and a developer warning is logged.
228+
"""
229+
trailing_url = "https://api.mailgun.net/v3/"
230+
231+
result = Config._normalize_api_url(trailing_url)
232+
233+
# 1. The suffix should be mathematically stripped
234+
assert result == "https://api.mailgun.net"
235+
236+
# 2. A semantic warning must be emitted for a developer
237+
mock_warn.assert_called_once()
238+
warning_msg = mock_warn.call_args[0][0]
239+
assert "Semantic Configuration Warning" in warning_msg
240+
assert "stripped to prevent routing duplication" in warning_msg
241+
242+
def test_normalize_api_url_raises_on_embedded_version(self) -> None:
243+
"""
244+
Verify the Fail-Fast branch:
245+
An embedded version (e.g., /v3/sandbox) raises a strict ApiError.
246+
"""
247+
ambiguous_url = "https://api.mailgun.net/v3/sandbox"
248+
249+
with pytest.raises(ApiError) as exc_info:
250+
Config._normalize_api_url(ambiguous_url)
251+
252+
error_msg = str(exc_info.value)
253+
assert "Ambiguous API URL configuration" in error_msg
254+
assert "embedded within your custom path" in error_msg
255+
assert "Please provide only the base host" in error_msg

0 commit comments

Comments
 (0)