Skip to content

Commit ded0852

Browse files
authored
fix: Propagate API token to custom HTTP clients (#956)
`ApifyClient.with_custom_http_client(token=...)` (and the async twin) stored the token on the `ApifyClient` instance but never passed it to the injected HTTP client, so no request carried an `Authorization` header and every call failed with 401. The documented custom HTTP client example inherited the bug. - `with_custom_http_client` now sets `Authorization: Bearer <token>` on the injected client's default headers, unless the client already has an auth header configured (checked case-insensitively). - `HttpClientBase._prepare_request_call` now merges the client's default headers under the per-request headers, so any custom client using the helper (including a pre-built `ImpitHttpClient` passed as the custom client) actually sends them. For the default client the wire behavior is unchanged, since impit request-level headers replace the identical client-level ones. - The HTTPX guide examples now merge `self._headers` before delegating, and the `HttpClient` ABC docstring states that implementations must send the default headers with every request. Regression tests cover the token reaching the wire (custom sync/async clients and a pre-built tokenless `ImpitHttpClient`) and the no-clobber semantics for client-configured auth headers.
1 parent f210385 commit ded0852

8 files changed

Lines changed: 297 additions & 14 deletions

File tree

docs/03_guides/code/05_custom_http_client_async.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ async def call(
3535
) -> HttpResponse:
3636
timeout_secs = self._compute_timeout(timeout, attempt=1) or 0
3737

38+
# Merge the client's default headers (including authorization)
39+
# with the per-request ones.
40+
headers = self._merge_headers(self._headers, headers)
41+
3842
# httpx.Response satisfies the HttpResponse protocol,
3943
# so it can be returned directly.
4044
return await self._client.request(

docs/03_guides/code/05_custom_http_client_sync.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ def call(
3434
) -> HttpResponse:
3535
timeout_secs = self._compute_timeout(timeout, attempt=1) or 0
3636

37+
# Merge the client's default headers (including authorization)
38+
# with the per-request ones.
39+
headers = self._merge_headers(self._headers, headers)
40+
3741
# httpx.Response satisfies the HttpResponse protocol,
3842
# so it can be returned directly.
3943
return self._client.request(

src/apify_client/_apify_client.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def with_custom_http_client(
227227
228228
Use this alternative constructor when you want to provide your own HTTP client implementation
229229
instead of the default one. The custom client is responsible for its own configuration
230-
(retries, timeouts, headers, etc.).
230+
(retries, timeouts, etc.); only the token is applied to it, as described below.
231231
232232
### Usage
233233
@@ -246,12 +246,15 @@ def call(self, *, method, url, **kwargs) -> HttpResponse:
246246
```
247247
248248
Args:
249-
token: The Apify API token.
249+
token: The Apify API token. It is set as the `Authorization` header on the custom client,
250+
unless the client already has one configured.
250251
api_url: The URL of the Apify API server to connect to. Defaults to https://api.apify.com.
251252
api_public_url: The globally accessible URL of the Apify API server. Defaults to https://api.apify.com.
252253
http_client: A custom HTTP client instance extending `HttpClient`.
253254
"""
254255
instance = cls(token=token, api_url=api_url, api_public_url=api_public_url)
256+
if token is not None:
257+
http_client.set_default_authorization(token)
255258
instance._http_client = http_client
256259
return instance
257260

@@ -586,7 +589,7 @@ def with_custom_http_client(
586589
587590
Use this alternative constructor when you want to provide your own HTTP client implementation
588591
instead of the default one. The custom client is responsible for its own configuration
589-
(retries, timeouts, headers, etc.).
592+
(retries, timeouts, etc.); only the token is applied to it, as described below.
590593
591594
### Usage
592595
@@ -605,12 +608,15 @@ async def call(self, *, method, url, **kwargs) -> HttpResponse:
605608
```
606609
607610
Args:
608-
token: The Apify API token.
611+
token: The Apify API token. It is set as the `Authorization` header on the custom client,
612+
unless the client already has one configured.
609613
api_url: The URL of the Apify API server to connect to. Defaults to https://api.apify.com.
610614
api_public_url: The globally accessible URL of the Apify API server. Defaults to https://api.apify.com.
611615
http_client: A custom HTTP client instance extending `HttpClientAsync`.
612616
"""
613617
instance = cls(token=token, api_url=api_url, api_public_url=api_public_url)
618+
if token is not None:
619+
http_client.set_default_authorization(token)
614620
instance._http_client = http_client
615621
return instance
616622

src/apify_client/http_clients/_base.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,30 @@ def __init__(
143143
if token is not None:
144144
default_headers['Authorization'] = f'Bearer {token}'
145145

146-
self._headers = {**default_headers, **(headers or {})}
146+
self._headers = self._merge_headers(default_headers, headers)
147+
148+
def set_default_authorization(self, token: str) -> None:
149+
"""Set the `Authorization` header from the token, unless an authorization header is already configured.
150+
151+
Args:
152+
token: The Apify API token to set as the `Bearer` authorization.
153+
"""
154+
if not any(key.lower() == 'authorization' for key in self._headers):
155+
self._headers['Authorization'] = f'Bearer {token}'
156+
157+
@staticmethod
158+
def _merge_headers(base: dict[str, str] | None, override: dict[str, str] | None) -> dict[str, str]:
159+
"""Merge two header dicts, treating header names case-insensitively.
160+
161+
A header from `override` replaces a same-named header in `base` regardless of the casing
162+
of either name, and keeps the casing it was passed with.
163+
"""
164+
merged = dict(base) if base else {}
165+
for key, value in (override or {}).items():
166+
for existing_key in [k for k in merged if k.lower() == key.lower()]:
167+
del merged[existing_key]
168+
merged[key] = value
169+
return merged
147170

148171
@staticmethod
149172
def _parse_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
@@ -207,24 +230,31 @@ def _prepare_request_call(
207230
data: str | bytes | bytearray | None = None,
208231
json: JsonSerializable | None = None,
209232
) -> tuple[dict[str, str], dict[str, Any] | None, bytes | None]:
210-
"""Prepare headers, params, and body for an HTTP request. Serializes JSON and compresses the body."""
233+
"""Prepare headers, params, and body for an HTTP request.
234+
235+
Merges the client's default headers (including authorization) with per-request headers,
236+
serializes JSON and compresses the body. Header names are treated case-insensitively and
237+
per-request values win over the client defaults. For JSON bodies, a `Content-Type` header
238+
is set unless the caller supplied one.
239+
"""
211240
if json is not None and data is not None:
212241
raise ValueError('Cannot pass both "json" and "data" parameters at the same time!')
213242

214-
headers = dict(headers) if headers else {}
243+
headers = self._merge_headers(self._headers, headers)
215244

216245
# Dump JSON data to string so it can be compressed.
217246
if json is not None:
218247
data = jsonlib.dumps(json, ensure_ascii=False, allow_nan=False, default=str).encode('utf-8')
219-
headers['Content-Type'] = 'application/json'
248+
if not any(key.lower() == 'content-type' for key in headers):
249+
headers['Content-Type'] = 'application/json'
220250

221251
if isinstance(data, (str, bytes, bytearray)):
222252
if isinstance(data, str):
223253
data = data.encode('utf-8')
224254
elif isinstance(data, bytearray):
225255
data = bytes(data)
226256
data = self._http_compressor.compress(data)
227-
headers['Content-Encoding'] = self._http_compressor.content_encoding
257+
headers = self._merge_headers(headers, {'Content-Encoding': self._http_compressor.content_encoding})
228258

229259
return (headers, self._parse_params(params), data)
230260

@@ -252,6 +282,10 @@ class HttpClient(HttpClientBase, ABC):
252282
Extend this class to create a custom synchronous HTTP client. Override the `call` method
253283
with your implementation. Helper methods from the base class are available for request
254284
preparation, URL building, and parameter parsing.
285+
286+
Implementations must send the client's default headers from `self._headers` with every request,
287+
otherwise the `Authorization` header never reaches the API. The `_prepare_request_call` helper
288+
merges them into the per-request headers automatically.
255289
"""
256290

257291
@abstractmethod

src/apify_client/http_clients/_impit.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ def __init__(
104104
)
105105

106106
self._impit_client = impit.Client(
107-
headers=self._headers,
108107
follow_redirects=True,
109108
)
110109

@@ -354,7 +353,6 @@ def __init__(
354353
)
355354

356355
self._impit_async_client = impit.AsyncClient(
357-
headers=self._headers,
358356
follow_redirects=True,
359357
)
360358

tests/unit/test_client_headers.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,33 @@ def test_headers_sync(httpserver: HTTPServer) -> None:
124124
}
125125
assert {k: v for k, v in request_headers.items() if k != 'Accept-Encoding'} == expected_headers
126126
assert _parse_accept_encoding(request_headers['Accept-Encoding']) == {'gzip', 'br', 'zstd', 'deflate'}
127+
128+
129+
async def test_per_request_headers_override_defaults_async(httpserver: HTTPServer) -> None:
130+
"""Test that a per-request header overrides a same-named default header on the wire, without duplication."""
131+
client = ImpitHttpClientAsync(token='placeholder_token')
132+
httpserver.expect_request('/').respond_with_handler(_header_handler)
133+
api_url = httpserver.url_for('/').removesuffix('/')
134+
135+
response = await client.call(method='GET', url=f'{api_url}/', headers={'authorization': 'Bearer per-request'})
136+
137+
request_headers = json.loads(response.text)['received_headers']
138+
139+
# WSGI joins duplicate headers into one comma-separated value, so exact equality
140+
# also proves the authorization header was sent only once.
141+
assert request_headers['Authorization'] == 'Bearer per-request'
142+
143+
144+
def test_per_request_headers_override_defaults_sync(httpserver: HTTPServer) -> None:
145+
"""Test that a per-request header overrides a same-named default header on the wire, without duplication."""
146+
client = ImpitHttpClient(token='placeholder_token')
147+
httpserver.expect_request('/').respond_with_handler(_header_handler)
148+
api_url = httpserver.url_for('/').removesuffix('/')
149+
150+
response = client.call(method='GET', url=f'{api_url}/', headers={'authorization': 'Bearer per-request'})
151+
152+
request_headers = json.loads(response.text)['received_headers']
153+
154+
# WSGI joins duplicate headers into one comma-separated value, so exact equality
155+
# also proves the authorization header was sent only once.
156+
assert request_headers['Authorization'] == 'Bearer per-request'

tests/unit/test_http_clients.py

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,53 @@ def test_base_http_client_initialization() -> None:
170170
assert isinstance(client2._statistics, ClientStatistics)
171171

172172

173+
def test_http_client_init_headers_override_defaults_case_insensitively() -> None:
174+
"""Constructor headers replace same-named default headers even when their casings differ."""
175+
client = _ConcreteHttpClient(token='default_token', headers={'authorization': 'Bearer custom'})
176+
177+
auth_headers = {key: value for key, value in client._headers.items() if key.lower() == 'authorization'}
178+
assert auth_headers == {'authorization': 'Bearer custom'}
179+
180+
181+
def test_http_client_init_workflow_key_header(monkeypatch: pytest.MonkeyPatch) -> None:
182+
"""The X-Apify-Workflow-Key default header is set from the APIFY_WORKFLOW_KEY env var."""
183+
monkeypatch.setenv('APIFY_WORKFLOW_KEY', 'workflow_key_123')
184+
185+
client = _ConcreteHttpClient()
186+
187+
assert client._headers['X-Apify-Workflow-Key'] == 'workflow_key_123'
188+
189+
190+
def test_set_default_authorization_sets_token_when_missing() -> None:
191+
"""set_default_authorization sets the Bearer token when no authorization header is configured."""
192+
client = _ConcreteHttpClient()
193+
194+
client.set_default_authorization('test_token')
195+
196+
assert client._headers['Authorization'] == 'Bearer test_token'
197+
198+
199+
@pytest.mark.parametrize(
200+
('base', 'override', 'expected'),
201+
[
202+
pytest.param(None, None, {}, id='both none'),
203+
pytest.param({'Accept': 'a'}, None, {'Accept': 'a'}, id='override none'),
204+
pytest.param(None, {'Accept': 'a'}, {'Accept': 'a'}, id='base none'),
205+
pytest.param({'Accept': 'a'}, {'X-Custom': 'b'}, {'Accept': 'a', 'X-Custom': 'b'}, id='disjoint names'),
206+
pytest.param({'Authorization': 'x'}, {'Authorization': 'y'}, {'Authorization': 'y'}, id='same casing'),
207+
pytest.param({'Authorization': 'x'}, {'authorization': 'y'}, {'authorization': 'y'}, id='lowercase override'),
208+
pytest.param({'authorization': 'x'}, {'AUTHORIZATION': 'y'}, {'AUTHORIZATION': 'y'}, id='uppercase override'),
209+
],
210+
)
211+
def test_merge_headers(
212+
base: dict[str, str] | None,
213+
override: dict[str, str] | None,
214+
expected: dict[str, str],
215+
) -> None:
216+
"""_merge_headers merges case-insensitively, override values win and keep their casing."""
217+
assert HttpClient._merge_headers(base, override) == expected
218+
219+
173220
def test_http_client_creates_sync_impit_client() -> None:
174221
"""Test that ImpitHttpClient creates sync impit client correctly."""
175222
client = ImpitHttpClient(token='test_token_123')
@@ -282,11 +329,13 @@ def compressor_case(request: pytest.FixtureRequest) -> tuple:
282329

283330

284331
def test_prepare_request_call_basic() -> None:
285-
"""Test _prepare_request_call with basic parameters."""
286-
client = _ConcreteHttpClient()
332+
"""Test _prepare_request_call returns the client default headers when no per-request values are given."""
333+
client = _ConcreteHttpClient(token='test_token')
287334

288335
headers, params, data = client._prepare_request_call()
289-
assert headers == {}
336+
assert headers == client._headers
337+
assert headers is not client._headers
338+
assert headers['Authorization'] == 'Bearer test_token'
290339
assert params is None
291340
assert data is None
292341

@@ -447,6 +496,39 @@ def test_prepare_request_call_does_not_mutate_caller_headers() -> None:
447496
assert caller_headers == original
448497

449498

499+
def test_prepare_request_call_per_request_headers_override_defaults_case_insensitively() -> None:
500+
"""A per-request header replaces a same-named default header even when their casings differ."""
501+
client = _ConcreteHttpClient(token='default_token')
502+
503+
headers, _params, _data = client._prepare_request_call(headers={'authorization': 'Bearer per-request'})
504+
505+
auth_headers = {key: value for key, value in headers.items() if key.lower() == 'authorization'}
506+
assert auth_headers == {'authorization': 'Bearer per-request'}
507+
508+
509+
def test_prepare_request_call_json_keeps_caller_content_type() -> None:
510+
"""A caller-supplied content type is not overwritten by the JSON default, regardless of casing."""
511+
client = _ConcreteHttpClient()
512+
513+
headers, _params, _data = client._prepare_request_call(
514+
headers={'content-type': 'application/json; charset=utf-8'},
515+
json={'key': 'value'},
516+
)
517+
518+
content_type_headers = {key: value for key, value in headers.items() if key.lower() == 'content-type'}
519+
assert content_type_headers == {'content-type': 'application/json; charset=utf-8'}
520+
521+
522+
def test_prepare_request_call_replaces_caller_content_encoding() -> None:
523+
"""The Content-Encoding header always reflects the compressor actually applied, replacing any caller value."""
524+
client = _ConcreteHttpClient(http_compressor=GzipHttpCompressor())
525+
526+
headers, _params, _data = client._prepare_request_call(headers={'content-encoding': 'br'}, data='payload')
527+
528+
encoding_headers = {key: value for key, value in headers.items() if key.lower() == 'content-encoding'}
529+
assert encoding_headers == {'Content-Encoding': 'gzip'}
530+
531+
450532
def test_build_url_with_params_none() -> None:
451533
"""Test _build_url_with_params with None params."""
452534
client = _ConcreteHttpClient()

0 commit comments

Comments
 (0)