usps-v3 1.0.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: pip install build twine
17
+ - run: python -m build
18
+ - run: python -m twine upload dist/*
19
+ env:
20
+ TWINE_USERNAME: __token__
21
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,30 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+
22
+ - name: Install dependencies
23
+ run: pip install -e ".[dev]"
24
+
25
+ - name: Run tests
26
+ run: pytest -v --tb=short
27
+
28
+ - name: Lint
29
+ if: matrix.python-version == '3.12'
30
+ run: ruff check src/ tests/
@@ -0,0 +1,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ *.egg
9
+ .eggs/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .venv/
13
+ venv/
14
+ env/
15
+ *.env
16
+ .DS_Store
17
+ *.pem
18
+ *.key
19
+ tokens.json
usps_v3-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James Lambert / Revasser LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
usps_v3-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.4
2
+ Name: usps-v3
3
+ Version: 1.0.0
4
+ Summary: Python SDK for USPS Web Tools v3 REST API — OAuth 2.0, address validation, tracking, labels, prices
5
+ Project-URL: Homepage, https://revaddress.com
6
+ Project-URL: Documentation, https://revaddress.com/docs
7
+ Project-URL: Repository, https://github.com/revereveal/usps-v3
8
+ Project-URL: Issues, https://github.com/revereveal/usps-v3/issues
9
+ Author-email: James Lambert <james@revasser.nyc>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: address-validation,api,labels,postal,shipping,tracking,usps
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: httpx>=0.24.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest-httpx>=0.21; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0; extra == 'dev'
29
+ Requires-Dist: respx>=0.20; extra == 'dev'
30
+ Requires-Dist: ruff>=0.3; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # usps-v3
34
+
35
+ Python SDK for the **USPS Web Tools v3 REST API** — the replacement for the retired XML-based Web Tools.
36
+
37
+ Direct USPS integration. OAuth 2.0. No middleman. No per-label fees.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install usps-v3
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```python
48
+ from usps_v3 import Client
49
+
50
+ # Credentials from USPS Business Customer Gateway
51
+ client = Client(client_id="your-id", client_secret="your-secret")
52
+ # Or set USPS_CLIENT_ID and USPS_CLIENT_SECRET environment variables:
53
+ # client = Client()
54
+
55
+ # Validate an address (FREE)
56
+ result = client.addresses.validate(
57
+ street_address="1600 Pennsylvania Ave NW",
58
+ city="Washington",
59
+ state="DC",
60
+ zip_code="20500",
61
+ )
62
+ print(result["address"]["ZIPPlus4"]) # "0005"
63
+
64
+ # Track a package (FREE)
65
+ info = client.tracking.track("9400111899223033005282")
66
+ print(info["statusCategory"]) # "Delivered"
67
+
68
+ # Get delivery time estimates (FREE)
69
+ standards = client.standards.estimates("10001", "90210")
70
+
71
+ # Find drop-off locations (FREE)
72
+ locations = client.locations.dropoff("20500", mail_class="PRIORITY_MAIL")
73
+
74
+ # Get rate quotes
75
+ rates = client.prices.domestic("10001", "90210", weight=2.5)
76
+ print(rates["rates"]["rateOptions"][0]["totalPrice"])
77
+
78
+ # International rates
79
+ intl = client.prices.international("10001", "CA", weight=3.0)
80
+
81
+ # Create shipping labels (requires USPS enrollment + COP claims)
82
+ label = client.labels.create(
83
+ from_address={"streetAddress": "123 Sender St", "city": "New York", "state": "NY", "ZIPCode": "10001"},
84
+ to_address={"streetAddress": "456 Recipient Ave", "city": "LA", "state": "CA", "ZIPCode": "90001"},
85
+ mail_class="PRIORITY_MAIL",
86
+ weight=2.0,
87
+ )
88
+ print(label["trackingNumber"])
89
+
90
+ # Void a label
91
+ client.labels.void("9400111899223033005282")
92
+ ```
93
+
94
+ ## Features
95
+
96
+ | Feature | Endpoint | Auth Required |
97
+ |---------|----------|--------------|
98
+ | Address Validation | `addresses.validate()` | OAuth only |
99
+ | City/State Lookup | `addresses.city_state()` | OAuth only |
100
+ | Package Tracking | `tracking.track()` | OAuth only |
101
+ | Service Standards | `standards.estimates()` | OAuth only |
102
+ | Drop-off Locations | `locations.dropoff()` | OAuth only |
103
+ | Domestic Prices | `prices.domestic()` | OAuth only |
104
+ | International Prices | `prices.international()` | OAuth only |
105
+ | Label Creation | `labels.create()` | OAuth + Payment Auth |
106
+ | Label Void | `labels.void()` | OAuth only |
107
+
108
+ ## Authentication
109
+
110
+ The SDK handles OAuth 2.0 token lifecycle automatically:
111
+
112
+ - **Token caching**: Tokens are cached in memory and on disk (`~/.usps-v3/tokens.json`)
113
+ - **Auto-refresh**: Tokens refresh automatically 30 minutes before expiry
114
+ - **Thread-safe**: Safe for concurrent use across threads
115
+
116
+ ### Getting Credentials
117
+
118
+ 1. Register at [USPS Business Customer Gateway](https://gateway.usps.com)
119
+ 2. Create an application in the API developer portal
120
+ 3. Note your `client_id` and `client_secret`
121
+
122
+ For label creation, you also need:
123
+ - **CRID** (Customer Registration ID)
124
+ - **MIDs** (Mailer IDs — master + label owner)
125
+ - **EPA** (Enterprise Payment Account)
126
+ - **COP claims linking** at [cop.usps.com](https://cop.usps.com)
127
+
128
+ ```python
129
+ client = Client(
130
+ client_id="...",
131
+ client_secret="...",
132
+ crid="56982563",
133
+ master_mid="904128936",
134
+ label_mid="904128937",
135
+ epa_account="1000405525",
136
+ )
137
+ ```
138
+
139
+ ## Error Handling
140
+
141
+ ```python
142
+ from usps_v3 import Client, AuthError, ValidationError, RateLimitError, APIError
143
+
144
+ try:
145
+ result = client.addresses.validate(street_address="123 Main St")
146
+ except ValidationError as e:
147
+ print(f"Bad input: {e} (field: {e.field})")
148
+ except RateLimitError as e:
149
+ print(f"Rate limited — retry after {e.retry_after}s")
150
+ except AuthError as e:
151
+ print(f"Auth failed: {e}")
152
+ except APIError as e:
153
+ print(f"USPS error ({e.status_code}): {e}")
154
+ ```
155
+
156
+ ## USPS Rate Limits
157
+
158
+ The v3 API defaults to **60 requests/hour** (down from unlimited in Web Tools). The SDK does not enforce this limit — USPS returns 429 when exceeded.
159
+
160
+ To request a higher limit, contact USPS at [emailus.usps.com](https://emailus.usps.com).
161
+
162
+ ## Migration from Web Tools
163
+
164
+ If you're migrating from the retired USPS Web Tools XML API:
165
+
166
+ | Web Tools (XML) | v3 SDK (Python) |
167
+ |-----------------|-----------------|
168
+ | `<AddressValidateRequest>` | `client.addresses.validate(...)` |
169
+ | `<CityStateLookupRequest>` | `client.addresses.city_state(...)` |
170
+ | `<TrackFieldRequest>` | `client.tracking.track(...)` |
171
+ | `<RateV4Request>` | `client.prices.domestic(...)` |
172
+ | User ID auth | OAuth 2.0 (automatic) |
173
+ | XML response parsing | Python dicts (automatic) |
174
+ | Unlimited requests | 60/hr default (request increase) |
175
+
176
+ ## Development
177
+
178
+ ```bash
179
+ git clone https://github.com/revereveal/usps-v3.git
180
+ cd usps-v3
181
+ pip install -e ".[dev]"
182
+ pytest -v
183
+ ```
184
+
185
+ ## License
186
+
187
+ MIT — see [LICENSE](LICENSE).
188
+
189
+ Built by [RevAddress](https://revaddress.com) — direct USPS API integration for developers.
@@ -0,0 +1,157 @@
1
+ # usps-v3
2
+
3
+ Python SDK for the **USPS Web Tools v3 REST API** — the replacement for the retired XML-based Web Tools.
4
+
5
+ Direct USPS integration. OAuth 2.0. No middleman. No per-label fees.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install usps-v3
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from usps_v3 import Client
17
+
18
+ # Credentials from USPS Business Customer Gateway
19
+ client = Client(client_id="your-id", client_secret="your-secret")
20
+ # Or set USPS_CLIENT_ID and USPS_CLIENT_SECRET environment variables:
21
+ # client = Client()
22
+
23
+ # Validate an address (FREE)
24
+ result = client.addresses.validate(
25
+ street_address="1600 Pennsylvania Ave NW",
26
+ city="Washington",
27
+ state="DC",
28
+ zip_code="20500",
29
+ )
30
+ print(result["address"]["ZIPPlus4"]) # "0005"
31
+
32
+ # Track a package (FREE)
33
+ info = client.tracking.track("9400111899223033005282")
34
+ print(info["statusCategory"]) # "Delivered"
35
+
36
+ # Get delivery time estimates (FREE)
37
+ standards = client.standards.estimates("10001", "90210")
38
+
39
+ # Find drop-off locations (FREE)
40
+ locations = client.locations.dropoff("20500", mail_class="PRIORITY_MAIL")
41
+
42
+ # Get rate quotes
43
+ rates = client.prices.domestic("10001", "90210", weight=2.5)
44
+ print(rates["rates"]["rateOptions"][0]["totalPrice"])
45
+
46
+ # International rates
47
+ intl = client.prices.international("10001", "CA", weight=3.0)
48
+
49
+ # Create shipping labels (requires USPS enrollment + COP claims)
50
+ label = client.labels.create(
51
+ from_address={"streetAddress": "123 Sender St", "city": "New York", "state": "NY", "ZIPCode": "10001"},
52
+ to_address={"streetAddress": "456 Recipient Ave", "city": "LA", "state": "CA", "ZIPCode": "90001"},
53
+ mail_class="PRIORITY_MAIL",
54
+ weight=2.0,
55
+ )
56
+ print(label["trackingNumber"])
57
+
58
+ # Void a label
59
+ client.labels.void("9400111899223033005282")
60
+ ```
61
+
62
+ ## Features
63
+
64
+ | Feature | Endpoint | Auth Required |
65
+ |---------|----------|--------------|
66
+ | Address Validation | `addresses.validate()` | OAuth only |
67
+ | City/State Lookup | `addresses.city_state()` | OAuth only |
68
+ | Package Tracking | `tracking.track()` | OAuth only |
69
+ | Service Standards | `standards.estimates()` | OAuth only |
70
+ | Drop-off Locations | `locations.dropoff()` | OAuth only |
71
+ | Domestic Prices | `prices.domestic()` | OAuth only |
72
+ | International Prices | `prices.international()` | OAuth only |
73
+ | Label Creation | `labels.create()` | OAuth + Payment Auth |
74
+ | Label Void | `labels.void()` | OAuth only |
75
+
76
+ ## Authentication
77
+
78
+ The SDK handles OAuth 2.0 token lifecycle automatically:
79
+
80
+ - **Token caching**: Tokens are cached in memory and on disk (`~/.usps-v3/tokens.json`)
81
+ - **Auto-refresh**: Tokens refresh automatically 30 minutes before expiry
82
+ - **Thread-safe**: Safe for concurrent use across threads
83
+
84
+ ### Getting Credentials
85
+
86
+ 1. Register at [USPS Business Customer Gateway](https://gateway.usps.com)
87
+ 2. Create an application in the API developer portal
88
+ 3. Note your `client_id` and `client_secret`
89
+
90
+ For label creation, you also need:
91
+ - **CRID** (Customer Registration ID)
92
+ - **MIDs** (Mailer IDs — master + label owner)
93
+ - **EPA** (Enterprise Payment Account)
94
+ - **COP claims linking** at [cop.usps.com](https://cop.usps.com)
95
+
96
+ ```python
97
+ client = Client(
98
+ client_id="...",
99
+ client_secret="...",
100
+ crid="56982563",
101
+ master_mid="904128936",
102
+ label_mid="904128937",
103
+ epa_account="1000405525",
104
+ )
105
+ ```
106
+
107
+ ## Error Handling
108
+
109
+ ```python
110
+ from usps_v3 import Client, AuthError, ValidationError, RateLimitError, APIError
111
+
112
+ try:
113
+ result = client.addresses.validate(street_address="123 Main St")
114
+ except ValidationError as e:
115
+ print(f"Bad input: {e} (field: {e.field})")
116
+ except RateLimitError as e:
117
+ print(f"Rate limited — retry after {e.retry_after}s")
118
+ except AuthError as e:
119
+ print(f"Auth failed: {e}")
120
+ except APIError as e:
121
+ print(f"USPS error ({e.status_code}): {e}")
122
+ ```
123
+
124
+ ## USPS Rate Limits
125
+
126
+ The v3 API defaults to **60 requests/hour** (down from unlimited in Web Tools). The SDK does not enforce this limit — USPS returns 429 when exceeded.
127
+
128
+ To request a higher limit, contact USPS at [emailus.usps.com](https://emailus.usps.com).
129
+
130
+ ## Migration from Web Tools
131
+
132
+ If you're migrating from the retired USPS Web Tools XML API:
133
+
134
+ | Web Tools (XML) | v3 SDK (Python) |
135
+ |-----------------|-----------------|
136
+ | `<AddressValidateRequest>` | `client.addresses.validate(...)` |
137
+ | `<CityStateLookupRequest>` | `client.addresses.city_state(...)` |
138
+ | `<TrackFieldRequest>` | `client.tracking.track(...)` |
139
+ | `<RateV4Request>` | `client.prices.domestic(...)` |
140
+ | User ID auth | OAuth 2.0 (automatic) |
141
+ | XML response parsing | Python dicts (automatic) |
142
+ | Unlimited requests | 60/hr default (request increase) |
143
+
144
+ ## Development
145
+
146
+ ```bash
147
+ git clone https://github.com/revereveal/usps-v3.git
148
+ cd usps-v3
149
+ pip install -e ".[dev]"
150
+ pytest -v
151
+ ```
152
+
153
+ ## License
154
+
155
+ MIT — see [LICENSE](LICENSE).
156
+
157
+ Built by [RevAddress](https://revaddress.com) — direct USPS API integration for developers.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "usps-v3"
7
+ version = "1.0.0"
8
+ description = "Python SDK for USPS Web Tools v3 REST API — OAuth 2.0, address validation, tracking, labels, prices"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "James Lambert", email = "james@revasser.nyc" }]
13
+ keywords = ["usps", "shipping", "address-validation", "tracking", "labels", "postal", "api"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Topic :: Office/Business :: Financial :: Point-Of-Sale",
26
+ ]
27
+ dependencies = ["httpx>=0.24.0"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://revaddress.com"
31
+ Documentation = "https://revaddress.com/docs"
32
+ Repository = "https://github.com/revereveal/usps-v3"
33
+ Issues = "https://github.com/revereveal/usps-v3/issues"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/usps_v3"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+
41
+ [project.optional-dependencies]
42
+ dev = ["pytest>=7.0", "pytest-httpx>=0.21", "respx>=0.20", "ruff>=0.3"]
@@ -0,0 +1,25 @@
1
+ """USPS v3 Python SDK — direct integration with USPS Web Tools v3 REST API.
2
+
3
+ Usage:
4
+ from usps_v3 import Client
5
+
6
+ client = Client(client_id="...", client_secret="...")
7
+ result = client.addresses.validate(street_address="1600 Pennsylvania Ave NW", city="Washington", state="DC")
8
+
9
+ Or with environment variables (USPS_CLIENT_ID, USPS_CLIENT_SECRET):
10
+ client = Client()
11
+ """
12
+
13
+ from .client import Client
14
+ from .exceptions import APIError, AuthError, NetworkError, RateLimitError, USPSError, ValidationError
15
+
16
+ __version__ = "1.0.0"
17
+ __all__ = [
18
+ "Client",
19
+ "USPSError",
20
+ "AuthError",
21
+ "ValidationError",
22
+ "RateLimitError",
23
+ "APIError",
24
+ "NetworkError",
25
+ ]
@@ -0,0 +1,108 @@
1
+ """Address validation and city/state lookup — USPS Addresses v3.
2
+
3
+ Free tier: no license required.
4
+
5
+ USPS endpoints:
6
+ GET /addresses/v3/address?streetAddress=...&city=...&state=...&ZIPCode=...
7
+ GET /addresses/v3/city-state?ZIPCode=...
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from .auth import TokenManager
17
+ from .exceptions import APIError, NetworkError, RateLimitError, ValidationError
18
+
19
+
20
+ class AddressesAPI:
21
+ """Address validation and ZIP code lookup."""
22
+
23
+ def __init__(self, http: httpx.Client, tokens: TokenManager, base_url: str):
24
+ self._http = http
25
+ self._tokens = tokens
26
+ self._base_url = base_url
27
+
28
+ def validate(
29
+ self,
30
+ street_address: str,
31
+ *,
32
+ secondary_address: str | None = None,
33
+ city: str | None = None,
34
+ state: str | None = None,
35
+ zip_code: str | None = None,
36
+ zip_plus4: str | None = None,
37
+ ) -> dict[str, Any]:
38
+ """Validate and standardize a US address.
39
+
40
+ Args:
41
+ street_address: Street address line (required).
42
+ secondary_address: Apt, Suite, etc.
43
+ city: City name.
44
+ state: 2-letter state code.
45
+ zip_code: 5-digit ZIP code.
46
+ zip_plus4: 4-digit ZIP+4 extension.
47
+
48
+ Returns:
49
+ Dict with 'address', 'additionalInfo', 'corrections', 'matches' keys.
50
+ The 'address' dict contains the standardized address fields.
51
+ """
52
+ if not street_address:
53
+ raise ValidationError("street_address is required", field="street_address")
54
+
55
+ params: dict[str, str] = {"streetAddress": street_address}
56
+ if secondary_address:
57
+ params["secondaryAddress"] = secondary_address
58
+ if city:
59
+ params["city"] = city
60
+ if state:
61
+ params["state"] = state
62
+ if zip_code:
63
+ params["ZIPCode"] = zip_code
64
+ if zip_plus4:
65
+ params["ZIPPlus4"] = zip_plus4
66
+
67
+ return self._request("GET", "/addresses/v3/address", params=params)
68
+
69
+ def city_state(self, zip_code: str) -> dict[str, Any]:
70
+ """Look up city and state for a ZIP code.
71
+
72
+ Args:
73
+ zip_code: 5-digit ZIP code.
74
+
75
+ Returns:
76
+ Dict with city and state information.
77
+ """
78
+ if not zip_code:
79
+ raise ValidationError("zip_code is required", field="zip_code")
80
+
81
+ return self._request("GET", "/addresses/v3/city-state", params={"ZIPCode": zip_code})
82
+
83
+ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
84
+ token = self._tokens.get_oauth_token()
85
+ headers = {"Authorization": f"Bearer {token}"}
86
+
87
+ try:
88
+ resp = self._http.request(
89
+ method,
90
+ f"{self._base_url}{path}",
91
+ headers=headers,
92
+ **kwargs,
93
+ )
94
+ except httpx.HTTPError as e:
95
+ raise NetworkError(f"Request failed: {e}") from e
96
+
97
+ if resp.status_code == 429:
98
+ retry_after = resp.headers.get("Retry-After")
99
+ raise RateLimitError(retry_after=int(retry_after) if retry_after else None)
100
+
101
+ if resp.status_code >= 400:
102
+ raise APIError(
103
+ f"USPS API error ({resp.status_code}): {resp.text}",
104
+ status_code=resp.status_code,
105
+ response_body=resp.text,
106
+ )
107
+
108
+ return resp.json()