reqkey 0.1.0__tar.gz → 0.2.1__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.
Files changed (26) hide show
  1. {reqkey-0.1.0 → reqkey-0.2.1}/PKG-INFO +142 -7
  2. {reqkey-0.1.0 → reqkey-0.2.1}/README.md +141 -6
  3. {reqkey-0.1.0 → reqkey-0.2.1}/pyproject.toml +1 -1
  4. {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/__init__.py +3 -2
  5. {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/_middleware.py +66 -0
  6. {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/asgi.py +145 -8
  7. {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/client.py +19 -1
  8. {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/django.py +247 -13
  9. {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/models.py +19 -2
  10. {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/wsgi.py +141 -13
  11. {reqkey-0.1.0 → reqkey-0.2.1}/tests/test_client.py +6 -0
  12. {reqkey-0.1.0 → reqkey-0.2.1}/tests/test_django.py +188 -6
  13. {reqkey-0.1.0 → reqkey-0.2.1}/tests/test_fastapi.py +97 -4
  14. reqkey-0.2.1/tests/test_middleware.py +35 -0
  15. {reqkey-0.1.0 → reqkey-0.2.1}/tests/test_wsgi.py +128 -5
  16. {reqkey-0.1.0 → reqkey-0.2.1}/.github/workflows/ci.yml +0 -0
  17. {reqkey-0.1.0 → reqkey-0.2.1}/.github/workflows/release.yml +0 -0
  18. {reqkey-0.1.0 → reqkey-0.2.1}/.gitignore +0 -0
  19. {reqkey-0.1.0 → reqkey-0.2.1}/LICENSE +0 -0
  20. {reqkey-0.1.0 → reqkey-0.2.1}/examples/django_settings.py +0 -0
  21. {reqkey-0.1.0 → reqkey-0.2.1}/examples/fastapi_app.py +0 -0
  22. {reqkey-0.1.0 → reqkey-0.2.1}/examples/flask_app.py +0 -0
  23. {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/exceptions.py +0 -0
  24. {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/fastapi.py +0 -0
  25. {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/flask.py +0 -0
  26. {reqkey-0.1.0 → reqkey-0.2.1}/src/reqkey/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reqkey
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: The official Python SDK for ReqKey API key validation and usage analytics
5
5
  Project-URL: Homepage, https://reqkey.com
6
6
  Project-URL: Documentation, https://reqkey.com/docs
@@ -76,6 +76,9 @@ Description-Content-Type: text/markdown
76
76
  The official Python SDK for API key validation, credit metering, consumer rate
77
77
  limits, and correlated API traffic analytics with ReqKey.
78
78
 
79
+ **Website:** [reqkey.com](https://reqkey.com) ·
80
+ **Documentation:** [reqkey.com/docs](https://reqkey.com/docs)
81
+
79
82
  One distribution contains the shared sync/async client and optional framework
80
83
  adapters. FastAPI, Starlette, Django, Flask, and generic ASGI/WSGI applications
81
84
  can all use ready-made middleware without implementing ReqKey request logic.
@@ -313,7 +316,7 @@ With `mode="both"`:
313
316
  Consumer request
314
317
  → extract consumer key
315
318
  → await /key/validate
316
- → denied: return 401 / 402 / 403 / 429
319
+ → denied: await /ingest, then return 401 / 402 / 403 / 429
317
320
  → approved: run the FastAPI endpoint
318
321
  → collect response metadata
319
322
  → await /ingest with the validation requestId
@@ -328,6 +331,11 @@ starting an unreliable background task:
328
331
  response.
329
332
  - Django's native middleware ingests before returning the response object.
330
333
 
334
+ Denied requests are recorded by default in `mode="both"`, including requests
335
+ with a missing key. This makes invalid-key attempts, exhausted consumers, and
336
+ rate-limit hammering visible in analytics. Set `ingest_denied_requests=False`
337
+ if you deliberately do not want those events or their ingestion cost.
338
+
331
339
  Streaming responses are forwarded chunk by chunk. The middleware keeps only
332
340
  enough data to reconstruct the first 1,000 response-body characters and holds
333
341
  the final ASGI completion message until ingestion finishes. It does not retain
@@ -354,6 +362,10 @@ iterator.
354
362
  | `credits` | `1` | Static cost or sync/async cost resolver. |
355
363
  | `exclude_paths` | empty | Exact paths or trailing-`*` prefix patterns. |
356
364
  | `skip_methods` | `("OPTIONS",)` | Methods that bypass ReqKey. |
365
+ | `consumer_name_resolver` | — | Resolve a display name from a request header, query parameter, or another trusted source. |
366
+ | `client_ip_resolver` | automatic | Override client-IP extraction for a trusted proxy setup. |
367
+ | `on_error` | — | Receive validation or ingestion service failures without exposing request secrets. |
368
+ | `ingest_denied_requests` | `True` | Record denied traffic in `mode="both"`. |
357
369
  | `failure_mode` | `"closed"` | Deny or allow when ReqKey itself is unavailable. |
358
370
  | `error_messages` | built in | Override customer-facing denial messages. |
359
371
  | `timeout` | `2.0` | Timeout for each ReqKey operation. |
@@ -539,6 +551,9 @@ Metadata sent by default:
539
551
  - response status
540
552
  - endpoint processing latency
541
553
  - user agent
554
+ - the consumer API key in the dedicated `apiKey` field, so ReqKey can resolve
555
+ the internal consumer name
556
+ - `consumerName` when a resolver supplies one
542
557
 
543
558
  Additional data is opt-in:
544
559
 
@@ -561,9 +576,75 @@ app.add_middleware(
561
576
  ```
562
577
 
563
578
  Authorization, cookies, `Set-Cookie`, common API-key headers, and the configured
564
- consumer-key header are always removed from captured headers. Response-body
565
- capture is limited to textual content and a hard maximum of 1,000 characters.
566
- The limit is enforced again by the HTTP client before `/ingest` is called.
579
+ consumer-key header are always removed from captured headers and paths. The
580
+ middleware sends the extracted consumer key only through `/ingest`'s dedicated
581
+ `apiKey` field for consumer resolution. Response-body capture is limited to
582
+ textual content and a hard maximum of 1,000 characters. The limit is enforced
583
+ again by the HTTP client before `/ingest` is called.
584
+
585
+ ### Consumer names
586
+
587
+ Use `consumer_name_resolver` when an upstream gateway or your application
588
+ provides a useful customer label. The resolver may read any part of the
589
+ framework request and may be synchronous or asynchronous in ASGI/Django:
590
+
591
+ ```python
592
+ def consumer_name(request: Request) -> str | None:
593
+ return (
594
+ request.headers.get("X-RapidAPI-User")
595
+ or request.query_params.get("consumerName")
596
+ )
597
+
598
+
599
+ app.add_middleware(
600
+ ReqKeyMiddleware,
601
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
602
+ api_id="api_payments",
603
+ consumer_name_resolver=consumer_name,
604
+ )
605
+ ```
606
+
607
+ The resolved value is optional analytics metadata. It is not used as the
608
+ consumer API key and does not change authorization. ReqKey resolves the
609
+ consumer display name in this order:
610
+
611
+ 1. A non-empty `consumerName` supplied by the resolver.
612
+ 2. The middleware's extracted consumer key, sent as `apiKey` for an internal
613
+ consumer lookup.
614
+ 3. A `consumerId` supplied to the direct client.
615
+
616
+ No resolver is required for normal ReqKey-managed consumers. The middleware
617
+ still sends the validation `requestId` for log correlation, but `/ingest` does
618
+ not use it to resolve consumer identity.
619
+
620
+ For Django, set `CONSUMER_NAME_RESOLVER` in `REQKEY`. For WSGI, the resolver
621
+ receives the WSGI environment, so a header can be read as
622
+ `environ.get("HTTP_X_RAPIDAPI_USER")`.
623
+
624
+ ### Client IPs and trusted proxies
625
+
626
+ Client-IP capture is opt-in with `capture_client_ip=True`. By default, the SDK
627
+ keeps the framework-provided peer address first: `request.client.host` for
628
+ ASGI, `REMOTE_ADDR` for WSGI, and `request.META["REMOTE_ADDR"]` for Django. It
629
+ only falls back when that value is absent, checking common proxy headers such
630
+ as `X-Forwarded-For`, `X-Real-IP`, Cloudflare, Vercel, Fly, Fastly, Azure,
631
+ App Engine, CloudFront, and standardized `Forwarded` headers.
632
+
633
+ If a trusted proxy always supplies the real client in a specific header, use a
634
+ resolver to override that default explicitly:
635
+
636
+ ```python
637
+ app.add_middleware(
638
+ ReqKeyMiddleware,
639
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
640
+ api_id="api_payments",
641
+ capture_client_ip=True,
642
+ client_ip_resolver=lambda request: request.headers.get("CF-Connecting-IP"),
643
+ )
644
+ ```
645
+
646
+ Only trust forwarding headers when your deployment proxy overwrites them;
647
+ otherwise a caller can spoof their value.
567
648
 
568
649
  ## Custom denial messages
569
650
 
@@ -616,8 +697,9 @@ The response includes these values when available:
616
697
  - `X-ReqKey-Credits-Remaining`
617
698
  - `X-ReqKey-Validation-Time-Ms`
618
699
 
619
- If browser JavaScript needs these headers, add them to the FastAPI CORS
620
- middleware's `expose_headers` list.
700
+ No CORS configuration is needed for server-to-server clients or for the server
701
+ itself to set these headers. Only cross-origin browser JavaScript that must read
702
+ them needs the names added to the CORS middleware's `expose_headers` list.
621
703
 
622
704
  ## Availability behavior
623
705
 
@@ -636,6 +718,44 @@ but it will not be correlated to a validated consumer.
636
718
  The SDK does not automatically retry `/key/validate`, because validation can
637
719
  deduct credits. Safe automatic retries require server-side idempotency.
638
720
 
721
+ ### Failure alerts
722
+
723
+ Webhook payload formats differ between Discord, Slack, Teams, and custom alert
724
+ receivers, so the SDK does not post one supposed universal webhook format.
725
+ Instead, `on_error` receives a provider-neutral `MiddlewareErrorEvent` for
726
+ ReqKey validation or ingestion failures:
727
+
728
+ ```python
729
+ from reqkey import MiddlewareErrorEvent
730
+
731
+
732
+ async def report_reqkey_failure(event: MiddlewareErrorEvent) -> None:
733
+ await send_to_your_alert_provider(
734
+ {
735
+ "operation": event.operation,
736
+ "message": event.message,
737
+ "method": event.method,
738
+ "path": event.path,
739
+ "request_id": event.request_id,
740
+ "status_code": event.status_code,
741
+ }
742
+ )
743
+
744
+
745
+ app.add_middleware(
746
+ ReqKeyMiddleware,
747
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
748
+ api_id="api_payments",
749
+ on_error=report_reqkey_failure,
750
+ )
751
+ ```
752
+
753
+ The event intentionally excludes API keys, project credentials, headers,
754
+ query parameters, and bodies. Exceptions raised by the callback are logged and
755
+ do not replace the application response. WSGI callbacks are synchronous;
756
+ ASGI callbacks may be synchronous or asynchronous, and Django follows the
757
+ sync/async mode of its middleware chain.
758
+
639
759
  ## Direct sync and async clients
640
760
 
641
761
  Async:
@@ -661,6 +781,21 @@ with ReqKey(project_key=os.environ["REQKEY_PROJECT_KEY"]) as reqkey:
661
781
  decision = reqkey.verify("consumer_key_...", api_id="api_payments")
662
782
  ```
663
783
 
784
+ Direct ingestion accepts the same consumer identity inputs as `/ingest`:
785
+
786
+ ```python
787
+ reqkey.ingest(
788
+ decision.request_id,
789
+ api_id="api_payments",
790
+ method="POST",
791
+ path="/payments",
792
+ status_code=201,
793
+ api_key="consumer_key_...", # resolves the internal consumer name
794
+ # consumer_name="rapidapi-user", # optional explicit override
795
+ # consumer_id="consumer_...", # fallback when no API key is available
796
+ )
797
+ ```
798
+
664
799
  ASGI and FastAPI use the asynchronous client and await both network operations.
665
800
  WSGI uses the synchronous client. Django automatically selects the correct
666
801
  client based on its middleware chain, avoiding blocking HTTP calls inside an
@@ -3,6 +3,9 @@
3
3
  The official Python SDK for API key validation, credit metering, consumer rate
4
4
  limits, and correlated API traffic analytics with ReqKey.
5
5
 
6
+ **Website:** [reqkey.com](https://reqkey.com) ·
7
+ **Documentation:** [reqkey.com/docs](https://reqkey.com/docs)
8
+
6
9
  One distribution contains the shared sync/async client and optional framework
7
10
  adapters. FastAPI, Starlette, Django, Flask, and generic ASGI/WSGI applications
8
11
  can all use ready-made middleware without implementing ReqKey request logic.
@@ -240,7 +243,7 @@ With `mode="both"`:
240
243
  Consumer request
241
244
  → extract consumer key
242
245
  → await /key/validate
243
- → denied: return 401 / 402 / 403 / 429
246
+ → denied: await /ingest, then return 401 / 402 / 403 / 429
244
247
  → approved: run the FastAPI endpoint
245
248
  → collect response metadata
246
249
  → await /ingest with the validation requestId
@@ -255,6 +258,11 @@ starting an unreliable background task:
255
258
  response.
256
259
  - Django's native middleware ingests before returning the response object.
257
260
 
261
+ Denied requests are recorded by default in `mode="both"`, including requests
262
+ with a missing key. This makes invalid-key attempts, exhausted consumers, and
263
+ rate-limit hammering visible in analytics. Set `ingest_denied_requests=False`
264
+ if you deliberately do not want those events or their ingestion cost.
265
+
258
266
  Streaming responses are forwarded chunk by chunk. The middleware keeps only
259
267
  enough data to reconstruct the first 1,000 response-body characters and holds
260
268
  the final ASGI completion message until ingestion finishes. It does not retain
@@ -281,6 +289,10 @@ iterator.
281
289
  | `credits` | `1` | Static cost or sync/async cost resolver. |
282
290
  | `exclude_paths` | empty | Exact paths or trailing-`*` prefix patterns. |
283
291
  | `skip_methods` | `("OPTIONS",)` | Methods that bypass ReqKey. |
292
+ | `consumer_name_resolver` | — | Resolve a display name from a request header, query parameter, or another trusted source. |
293
+ | `client_ip_resolver` | automatic | Override client-IP extraction for a trusted proxy setup. |
294
+ | `on_error` | — | Receive validation or ingestion service failures without exposing request secrets. |
295
+ | `ingest_denied_requests` | `True` | Record denied traffic in `mode="both"`. |
284
296
  | `failure_mode` | `"closed"` | Deny or allow when ReqKey itself is unavailable. |
285
297
  | `error_messages` | built in | Override customer-facing denial messages. |
286
298
  | `timeout` | `2.0` | Timeout for each ReqKey operation. |
@@ -466,6 +478,9 @@ Metadata sent by default:
466
478
  - response status
467
479
  - endpoint processing latency
468
480
  - user agent
481
+ - the consumer API key in the dedicated `apiKey` field, so ReqKey can resolve
482
+ the internal consumer name
483
+ - `consumerName` when a resolver supplies one
469
484
 
470
485
  Additional data is opt-in:
471
486
 
@@ -488,9 +503,75 @@ app.add_middleware(
488
503
  ```
489
504
 
490
505
  Authorization, cookies, `Set-Cookie`, common API-key headers, and the configured
491
- consumer-key header are always removed from captured headers. Response-body
492
- capture is limited to textual content and a hard maximum of 1,000 characters.
493
- The limit is enforced again by the HTTP client before `/ingest` is called.
506
+ consumer-key header are always removed from captured headers and paths. The
507
+ middleware sends the extracted consumer key only through `/ingest`'s dedicated
508
+ `apiKey` field for consumer resolution. Response-body capture is limited to
509
+ textual content and a hard maximum of 1,000 characters. The limit is enforced
510
+ again by the HTTP client before `/ingest` is called.
511
+
512
+ ### Consumer names
513
+
514
+ Use `consumer_name_resolver` when an upstream gateway or your application
515
+ provides a useful customer label. The resolver may read any part of the
516
+ framework request and may be synchronous or asynchronous in ASGI/Django:
517
+
518
+ ```python
519
+ def consumer_name(request: Request) -> str | None:
520
+ return (
521
+ request.headers.get("X-RapidAPI-User")
522
+ or request.query_params.get("consumerName")
523
+ )
524
+
525
+
526
+ app.add_middleware(
527
+ ReqKeyMiddleware,
528
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
529
+ api_id="api_payments",
530
+ consumer_name_resolver=consumer_name,
531
+ )
532
+ ```
533
+
534
+ The resolved value is optional analytics metadata. It is not used as the
535
+ consumer API key and does not change authorization. ReqKey resolves the
536
+ consumer display name in this order:
537
+
538
+ 1. A non-empty `consumerName` supplied by the resolver.
539
+ 2. The middleware's extracted consumer key, sent as `apiKey` for an internal
540
+ consumer lookup.
541
+ 3. A `consumerId` supplied to the direct client.
542
+
543
+ No resolver is required for normal ReqKey-managed consumers. The middleware
544
+ still sends the validation `requestId` for log correlation, but `/ingest` does
545
+ not use it to resolve consumer identity.
546
+
547
+ For Django, set `CONSUMER_NAME_RESOLVER` in `REQKEY`. For WSGI, the resolver
548
+ receives the WSGI environment, so a header can be read as
549
+ `environ.get("HTTP_X_RAPIDAPI_USER")`.
550
+
551
+ ### Client IPs and trusted proxies
552
+
553
+ Client-IP capture is opt-in with `capture_client_ip=True`. By default, the SDK
554
+ keeps the framework-provided peer address first: `request.client.host` for
555
+ ASGI, `REMOTE_ADDR` for WSGI, and `request.META["REMOTE_ADDR"]` for Django. It
556
+ only falls back when that value is absent, checking common proxy headers such
557
+ as `X-Forwarded-For`, `X-Real-IP`, Cloudflare, Vercel, Fly, Fastly, Azure,
558
+ App Engine, CloudFront, and standardized `Forwarded` headers.
559
+
560
+ If a trusted proxy always supplies the real client in a specific header, use a
561
+ resolver to override that default explicitly:
562
+
563
+ ```python
564
+ app.add_middleware(
565
+ ReqKeyMiddleware,
566
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
567
+ api_id="api_payments",
568
+ capture_client_ip=True,
569
+ client_ip_resolver=lambda request: request.headers.get("CF-Connecting-IP"),
570
+ )
571
+ ```
572
+
573
+ Only trust forwarding headers when your deployment proxy overwrites them;
574
+ otherwise a caller can spoof their value.
494
575
 
495
576
  ## Custom denial messages
496
577
 
@@ -543,8 +624,9 @@ The response includes these values when available:
543
624
  - `X-ReqKey-Credits-Remaining`
544
625
  - `X-ReqKey-Validation-Time-Ms`
545
626
 
546
- If browser JavaScript needs these headers, add them to the FastAPI CORS
547
- middleware's `expose_headers` list.
627
+ No CORS configuration is needed for server-to-server clients or for the server
628
+ itself to set these headers. Only cross-origin browser JavaScript that must read
629
+ them needs the names added to the CORS middleware's `expose_headers` list.
548
630
 
549
631
  ## Availability behavior
550
632
 
@@ -563,6 +645,44 @@ but it will not be correlated to a validated consumer.
563
645
  The SDK does not automatically retry `/key/validate`, because validation can
564
646
  deduct credits. Safe automatic retries require server-side idempotency.
565
647
 
648
+ ### Failure alerts
649
+
650
+ Webhook payload formats differ between Discord, Slack, Teams, and custom alert
651
+ receivers, so the SDK does not post one supposed universal webhook format.
652
+ Instead, `on_error` receives a provider-neutral `MiddlewareErrorEvent` for
653
+ ReqKey validation or ingestion failures:
654
+
655
+ ```python
656
+ from reqkey import MiddlewareErrorEvent
657
+
658
+
659
+ async def report_reqkey_failure(event: MiddlewareErrorEvent) -> None:
660
+ await send_to_your_alert_provider(
661
+ {
662
+ "operation": event.operation,
663
+ "message": event.message,
664
+ "method": event.method,
665
+ "path": event.path,
666
+ "request_id": event.request_id,
667
+ "status_code": event.status_code,
668
+ }
669
+ )
670
+
671
+
672
+ app.add_middleware(
673
+ ReqKeyMiddleware,
674
+ project_key=os.environ["REQKEY_PROJECT_KEY"],
675
+ api_id="api_payments",
676
+ on_error=report_reqkey_failure,
677
+ )
678
+ ```
679
+
680
+ The event intentionally excludes API keys, project credentials, headers,
681
+ query parameters, and bodies. Exceptions raised by the callback are logged and
682
+ do not replace the application response. WSGI callbacks are synchronous;
683
+ ASGI callbacks may be synchronous or asynchronous, and Django follows the
684
+ sync/async mode of its middleware chain.
685
+
566
686
  ## Direct sync and async clients
567
687
 
568
688
  Async:
@@ -588,6 +708,21 @@ with ReqKey(project_key=os.environ["REQKEY_PROJECT_KEY"]) as reqkey:
588
708
  decision = reqkey.verify("consumer_key_...", api_id="api_payments")
589
709
  ```
590
710
 
711
+ Direct ingestion accepts the same consumer identity inputs as `/ingest`:
712
+
713
+ ```python
714
+ reqkey.ingest(
715
+ decision.request_id,
716
+ api_id="api_payments",
717
+ method="POST",
718
+ path="/payments",
719
+ status_code=201,
720
+ api_key="consumer_key_...", # resolves the internal consumer name
721
+ # consumer_name="rapidapi-user", # optional explicit override
722
+ # consumer_id="consumer_...", # fallback when no API key is available
723
+ )
724
+ ```
725
+
591
726
  ASGI and FastAPI use the asynchronous client and await both network operations.
592
727
  WSGI uses the synchronous client. Django automatically selects the correct
593
728
  client based on its middleware chain, avoiding blocking HTTP calls inside an
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "reqkey"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "The official Python SDK for ReqKey API key validation and usage analytics"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -9,12 +9,13 @@ from .exceptions import (
9
9
  ReqKeyTimeoutError,
10
10
  ReqKeyTransportError,
11
11
  )
12
- from .models import VerificationReason, VerificationResult
12
+ from .models import MiddlewareErrorEvent, VerificationReason, VerificationResult
13
13
 
14
- __version__ = "0.1.0"
14
+ __version__ = "0.2.1"
15
15
 
16
16
  __all__ = [
17
17
  "AsyncReqKey",
18
+ "MiddlewareErrorEvent",
18
19
  "ReqKey",
19
20
  "ReqKeyAPIError",
20
21
  "ReqKeyAuthenticationError",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import ipaddress
5
6
  from collections.abc import Iterable, Mapping
6
7
  from typing import Literal
7
8
 
@@ -32,6 +33,20 @@ DEFAULT_EXCLUDED_HEADERS = frozenset(
32
33
  }
33
34
  )
34
35
 
36
+ PROXY_CLIENT_IP_HEADERS = (
37
+ "x-forwarded-for",
38
+ "x-real-ip",
39
+ "cf-connecting-ip",
40
+ "true-client-ip",
41
+ "x-vercel-forwarded-for",
42
+ "fly-client-ip",
43
+ "fastly-client-ip",
44
+ "x-azure-clientip",
45
+ "x-appengine-user-ip",
46
+ "cloudfront-viewer-address",
47
+ "forwarded",
48
+ )
49
+
35
50
 
36
51
  def validate_middleware_options(
37
52
  *,
@@ -130,3 +145,54 @@ def filtered_headers(
130
145
  for key, value in headers.items()
131
146
  if key.lower() not in excluded
132
147
  }
148
+
149
+
150
+ def client_ip_from_headers(headers: Mapping[str, str]) -> str | None:
151
+ """Return the first valid IP from common proxy headers.
152
+
153
+ Middleware adapters use this only when the framework did not provide a
154
+ client address. Applications that intentionally trust a proxy header can
155
+ override the behavior with ``client_ip_resolver``.
156
+ """
157
+
158
+ normalized = {key.lower(): value for key, value in headers.items()}
159
+ for name in PROXY_CLIENT_IP_HEADERS:
160
+ value = normalized.get(name)
161
+ if not value:
162
+ continue
163
+ candidates = _forwarded_candidates(value) if name == "forwarded" else value.split(",")
164
+ for candidate in candidates:
165
+ parsed = _parse_ip(candidate)
166
+ if parsed is not None:
167
+ return parsed
168
+ return None
169
+
170
+
171
+ def _forwarded_candidates(value: str) -> list[str]:
172
+ candidates: list[str] = []
173
+ for element in value.split(","):
174
+ for parameter in element.split(";"):
175
+ name, separator, raw = parameter.strip().partition("=")
176
+ if separator and name.lower() == "for":
177
+ candidates.append(raw)
178
+ return candidates
179
+
180
+
181
+ def _parse_ip(value: str) -> str | None:
182
+ candidate = value.strip().strip('"')
183
+ if not candidate or candidate.lower() == "unknown" or candidate.startswith("_"):
184
+ return None
185
+ if candidate.startswith("["):
186
+ end = candidate.find("]")
187
+ if end > 0:
188
+ candidate = candidate[1:end]
189
+ else:
190
+ try:
191
+ return str(ipaddress.ip_address(candidate))
192
+ except ValueError:
193
+ if candidate.count(":") == 1:
194
+ candidate = candidate.partition(":")[0]
195
+ try:
196
+ return str(ipaddress.ip_address(candidate))
197
+ except ValueError:
198
+ return None