agentpayments-python 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
@@ -89,6 +89,14 @@ class GateMiddleware:
89
89
  # Optional grant store for durable paid-key persistence. Set
90
90
  # AGENTPAYMENTS_GRANT_STORE to a GrantStore instance in settings.py.
91
91
  self.grant_store = getattr(settings, "AGENTPAYMENTS_GRANT_STORE", None)
92
+ # Pluggable rate limiters / payment cache — default to the built-in
93
+ # in-memory singletons (fine for single-process deployments). For
94
+ # multi-process (e.g. gunicorn -w 4), set these to Redis-backed
95
+ # instances from agentpayments_python.redis_store so state is shared
96
+ # across workers instead of each worker enforcing its own limit.
97
+ self.agent_key_rate_limiter = getattr(settings, "AGENTPAYMENTS_AGENT_KEY_RATE_LIMITER", None) or _agent_key_limiter
98
+ self.challenge_issue_rate_limiter = getattr(settings, "AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER", None) or _challenge_issue_limiter
99
+ self.payment_cache = getattr(settings, "AGENTPAYMENTS_PAYMENT_CACHE", None)
92
100
 
93
101
  def __call__(self, request):
94
102
  secret = self.secret
@@ -177,7 +185,7 @@ class GateMiddleware:
177
185
  elif not is_valid_agent_key(agent_key, secret):
178
186
  return JsonResponse({"error": "forbidden", "message": "Invalid API key. Keys must be issued by this server."}, status=403)
179
187
 
180
- if not _agent_key_limiter.check(_client_ip(request)):
188
+ if not self.agent_key_rate_limiter.check(_client_ip(request)):
181
189
  return JsonResponse({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}, status=429)
182
190
 
183
191
  if not wallet_address:
@@ -187,7 +195,7 @@ class GateMiddleware:
187
195
  if self.grant_store and self.grant_store.has(agent_key):
188
196
  return self.get_response(request)
189
197
 
190
- scan_result = _scan_for_payment(agent_key, wallet_address, self.rpc_url, self.usdc_mint, min_payment=price_config["min_payment"], fee_info=fee_info)
198
+ scan_result = _scan_for_payment(agent_key, wallet_address, self.rpc_url, self.usdc_mint, min_payment=price_config["min_payment"], fee_info=fee_info, payment_cache=self.payment_cache)
191
199
  paid = scan_result["paid"]
192
200
  if paid and self.grant_store:
193
201
  tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
@@ -215,7 +223,7 @@ class GateMiddleware:
215
223
  return self.get_response(request)
216
224
 
217
225
  # Rate-limit challenge page issuance to prevent unlimited nonce harvesting.
218
- if not _challenge_issue_limiter.check(client_ip):
226
+ if not self.challenge_issue_rate_limiter.check(client_ip):
219
227
  return JsonResponse({"error": "rate_limited", "message": "Too many requests. Please try again later."}, status=429)
220
228
 
221
229
  nonce = make_nonce(secret, client_ip)
@@ -230,7 +238,10 @@ class GateMiddleware:
230
238
  @require_POST
231
239
  def challenge_verify(request):
232
240
  client_ip = _client_ip(request)
233
- if not _challenge_limiter.check(client_ip):
241
+ # Optional: set AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER in settings.py
242
+ # to a Redis-backed limiter for multi-process deployments.
243
+ limiter = getattr(settings, "AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER", None) or _challenge_limiter
244
+ if not limiter.check(client_ip):
234
245
  return JsonResponse({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}, status=429)
235
246
  secret = settings.CHALLENGE_SECRET
236
247
  nonce = request.POST.get("nonce", "")[:MAX_NONCE_LENGTH]
@@ -46,7 +46,7 @@ def _client_ip(request: Request) -> str:
46
46
 
47
47
 
48
48
  class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
49
- def __init__(self, app, *, challenge_secret: str, home_wallet_address: str, debug: bool = True, solana_rpc_url=None, usdc_mint: str = "", min_payment: float = MIN_PAYMENT, access_duration: float | None = None, pricing_tiers: list[dict] | None = None, routes: list[dict] | None = None, pow_difficulty: int = POW_DIFFICULTY, verify_crawlers: bool = True, grant_store=None, require_https: bool = None, api_key: str = None, platform_url: str = None):
49
+ def __init__(self, app, *, challenge_secret: str, home_wallet_address: str, debug: bool = True, solana_rpc_url=None, usdc_mint: str = "", min_payment: float = MIN_PAYMENT, access_duration: float | None = None, pricing_tiers: list[dict] | None = None, routes: list[dict] | None = None, pow_difficulty: int = POW_DIFFICULTY, verify_crawlers: bool = True, grant_store=None, require_https: bool = None, api_key: str = None, platform_url: str = None, agent_key_rate_limiter=None, challenge_issue_rate_limiter=None, payment_cache=None):
50
50
  super().__init__(app)
51
51
  if challenge_secret == "default-secret-change-me":
52
52
  import logging
@@ -72,6 +72,14 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
72
72
  self.grant_store = grant_store
73
73
  self.require_https = (not debug) if require_https is None else require_https
74
74
  self._platform_client = PlatformClient(api_key, platform_url) if api_key else None
75
+ # Pluggable rate limiter / payment cache — default to the built-in
76
+ # in-memory singletons (fine for single-process deployments). For
77
+ # multi-process (e.g. gunicorn -w 4), pass Redis-backed instances
78
+ # from agentpayments_python.redis_store so state is shared across
79
+ # workers instead of each worker enforcing its own independent limit.
80
+ self.agent_key_rate_limiter = agent_key_rate_limiter or _agent_key_limiter
81
+ self.challenge_issue_rate_limiter = challenge_issue_rate_limiter or _challenge_issue_limiter
82
+ self.payment_cache = payment_cache
75
83
 
76
84
  async def dispatch(self, request: Request, call_next):
77
85
  path = request.url.path
@@ -154,7 +162,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
154
162
  elif not is_valid_agent_key(agent_key, self.challenge_secret):
155
163
  return JSONResponse({"error": "forbidden", "message": "Invalid API key."}, status_code=403)
156
164
 
157
- if not _agent_key_limiter.check(_client_ip(request)):
165
+ if not self.agent_key_rate_limiter.check(_client_ip(request)):
158
166
  return JSONResponse({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}, status_code=429)
159
167
 
160
168
  if not self.home_wallet_address:
@@ -167,7 +175,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
167
175
  # _scan_for_payment is synchronous (uses requests). Run it in a
168
176
  # thread-pool executor so it doesn't block the async event loop.
169
177
  scan_result = await loop.run_in_executor(
170
- None, lambda: _scan_for_payment(agent_key, self.home_wallet_address, self.solana_rpc_url, self.usdc_mint, min_payment=price_config["min_payment"], fee_info=fee_info)
178
+ None, lambda: _scan_for_payment(agent_key, self.home_wallet_address, self.solana_rpc_url, self.usdc_mint, min_payment=price_config["min_payment"], fee_info=fee_info, payment_cache=self.payment_cache)
171
179
  )
172
180
  paid = scan_result["paid"]
173
181
  if paid and self.grant_store:
@@ -195,7 +203,7 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
195
203
  if is_valid_cookie_value(cookie_val, self.challenge_secret, client_ip):
196
204
  return await call_next(request)
197
205
 
198
- if not _challenge_issue_limiter.check(client_ip):
206
+ if not self.challenge_issue_rate_limiter.check(client_ip):
199
207
  return JSONResponse({"error": "rate_limited", "message": "Too many requests. Please try again later."}, status_code=429)
200
208
 
201
209
  nonce = make_nonce(self.challenge_secret, client_ip)
@@ -206,9 +214,10 @@ class AgentPaymentsASGIMiddleware(BaseHTTPMiddleware):
206
214
  })
207
215
 
208
216
 
209
- async def challenge_verify_endpoint(request: Request, challenge_secret: str, pow_difficulty: int = POW_DIFFICULTY):
217
+ async def challenge_verify_endpoint(request: Request, challenge_secret: str, pow_difficulty: int = POW_DIFFICULTY, rate_limiter=None):
210
218
  client_ip = _client_ip(request)
211
- if not _challenge_limiter.check(client_ip):
219
+ limiter = rate_limiter or _challenge_limiter
220
+ if not limiter.check(client_ip):
212
221
  return JSONResponse({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}, status_code=429)
213
222
  form = await request.form()
214
223
  nonce = str(form.get("nonce", ""))[:MAX_NONCE_LENGTH]
@@ -39,7 +39,15 @@ def _client_ip() -> str:
39
39
  return request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr or "unknown"
40
40
 
41
41
 
42
- def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: str, debug: bool = True, solana_rpc_url=None, usdc_mint: str = "", min_payment: float = MIN_PAYMENT, access_duration: float | None = None, pricing_tiers: list[dict] | None = None, routes: list[dict] | None = None, pow_difficulty: int = POW_DIFFICULTY, verify_crawlers: bool = True, grant_store=None, require_https: bool = None, api_key: str = None, platform_url: str = None):
42
+ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: str, debug: bool = True, solana_rpc_url=None, usdc_mint: str = "", min_payment: float = MIN_PAYMENT, access_duration: float | None = None, pricing_tiers: list[dict] | None = None, routes: list[dict] | None = None, pow_difficulty: int = POW_DIFFICULTY, verify_crawlers: bool = True, grant_store=None, require_https: bool = None, api_key: str = None, platform_url: str = None, challenge_verify_rate_limiter=None, agent_key_rate_limiter=None, challenge_issue_rate_limiter=None, payment_cache=None):
43
+ # Pluggable rate limiters / payment cache — default to the built-in
44
+ # in-memory singletons (fine for single-process deployments). For
45
+ # multi-process (e.g. gunicorn -w 4), pass Redis-backed instances from
46
+ # agentpayments_python.redis_store so state is shared across workers
47
+ # instead of each worker enforcing its own independent limit.
48
+ _challenge_verify_limiter = challenge_verify_rate_limiter or _challenge_limiter
49
+ _agent_key_rate_limiter = agent_key_rate_limiter or _agent_key_limiter
50
+ _challenge_issue_rate_limiter = challenge_issue_rate_limiter or _challenge_issue_limiter
43
51
  if challenge_secret == "default-secret-change-me":
44
52
  import logging
45
53
  logger = logging.getLogger("agentpayments")
@@ -133,13 +141,13 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
133
141
  return jsonify({"error": "forbidden", "message": "Invalid API key."}), 403
134
142
  elif not is_valid_agent_key(key, challenge_secret):
135
143
  return jsonify({"error": "forbidden", "message": "Invalid API key."}), 403
136
- if not _agent_key_limiter.check(_client_ip()):
144
+ if not _agent_key_rate_limiter.check(_client_ip()):
137
145
  return jsonify({"error": "rate_limited", "message": "Too many payment verification requests. Please wait and try again."}), 429
138
146
  if not home_wallet_address:
139
147
  return jsonify({"error": "server_error", "message": "Payment verification unavailable."}), 500
140
148
  if grant_store and grant_store.has(key):
141
149
  return None
142
- scan_result = _scan_for_payment(key, home_wallet_address, rpc_url, mint, min_payment=price_config["min_payment"], fee_info=fee_info)
150
+ scan_result = _scan_for_payment(key, home_wallet_address, rpc_url, mint, min_payment=price_config["min_payment"], fee_info=fee_info, payment_cache=payment_cache)
143
151
  paid = scan_result["paid"]
144
152
  if paid and grant_store:
145
153
  tier = resolve_tier(scan_result["amount_paid"], price_config["pricing_tiers"])
@@ -165,7 +173,7 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
165
173
  if is_valid_cookie_value(cookie_val, challenge_secret, client_ip):
166
174
  return None
167
175
 
168
- if not _challenge_issue_limiter.check(client_ip):
176
+ if not _challenge_issue_rate_limiter.check(client_ip):
169
177
  return make_response(_flask_json.dumps({"error": "rate_limited", "message": "Too many requests. Please try again later."}, indent=2), 429, {"Content-Type": "application/json"})
170
178
 
171
179
  nonce = make_nonce(challenge_secret, client_ip)
@@ -179,7 +187,7 @@ def register_agentpayments(app, *, challenge_secret: str, home_wallet_address: s
179
187
  @app.post("/__challenge/verify")
180
188
  def _verify():
181
189
  client_ip = _client_ip()
182
- if not _challenge_limiter.check(client_ip):
190
+ if not _challenge_verify_limiter.check(client_ip):
183
191
  return jsonify({"error": "rate_limited", "message": "Too many verification attempts. Please wait and try again."}), 429
184
192
  nonce = request.form.get("nonce", "")[:MAX_NONCE_LENGTH]
185
193
  return_to = request.form.get("return_to", "/")[:MAX_RETURN_TO_LENGTH]
@@ -0,0 +1,128 @@
1
+ """
2
+ RedisStore -- pluggable state backend for the Python SDK using Redis.
3
+
4
+ Designed for multi-process Django/FastAPI/Flask deployments (e.g.
5
+ `gunicorn -w 4`) where the built-in in-memory rate limiters and payment
6
+ cache are per-process and therefore far less effective than intended: a
7
+ paid key's positive/negative cache result, and each IP's rate-limit
8
+ count, only apply within the one worker process that happened to handle
9
+ that request.
10
+
11
+ Usage:
12
+
13
+ import redis
14
+ from agentpayments_python.redis_store import create_redis_store
15
+
16
+ r = redis.Redis.from_url(os.environ["REDIS_URL"])
17
+ store = create_redis_store(r)
18
+
19
+ # FastAPI / Flask constructor kwargs
20
+ register_agentpayments(app, ...,
21
+ agent_key_rate_limiter=store["agent_key_rate_limiter"],
22
+ challenge_verify_rate_limiter=store["challenge_verify_rate_limiter"],
23
+ challenge_issue_rate_limiter=store["challenge_issue_rate_limiter"],
24
+ payment_cache=store["payment_cache"],
25
+ )
26
+
27
+ # Django settings.py
28
+ AGENTPAYMENTS_AGENT_KEY_RATE_LIMITER = store["agent_key_rate_limiter"]
29
+ AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER = store["challenge_verify_rate_limiter"]
30
+ AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER = store["challenge_issue_rate_limiter"]
31
+ AGENTPAYMENTS_PAYMENT_CACHE = store["payment_cache"]
32
+
33
+ Duck-typed against any client exposing `eval(script, numkeys, *keys_and_args)`,
34
+ `get(key)`, and `set(key, value, ex=ttl_seconds)` -- redis-py's `Redis`
35
+ client satisfies this directly. This module has no hard dependency on the
36
+ `redis` package itself (mirrors sdk/node/redis-store.js) -- bring your own
37
+ client.
38
+
39
+ Atomicity: RateLimiter uses a Lua INCR+EXPIRE script executed atomically
40
+ server-side, so there is no read-modify-write race under concurrent
41
+ requests. PaymentCache uses plain SET EX.
42
+
43
+ Fails open on Redis errors -- a Redis outage must not block legitimate
44
+ traffic (rate limiter allows the request through) or brick payment
45
+ verification (a cache error just falls through to a normal chain scan).
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import logging
51
+
52
+ logger = logging.getLogger("agentpayments")
53
+
54
+ RATE_LIMIT_WINDOW = 60 # seconds
55
+ RATE_LIMIT_MAX = 20
56
+ AGENT_KEY_RATE_LIMIT_MAX = 10
57
+ CHALLENGE_ISSUE_RATE_LIMIT_MAX = 30
58
+
59
+ # Atomically increment and set a TTL only on the first hit in the window.
60
+ # Returns the current count after increment.
61
+ _INCR_SCRIPT = """
62
+ local current = redis.call('INCR', KEYS[1])
63
+ if current == 1 then
64
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
65
+ end
66
+ return current
67
+ """
68
+
69
+
70
+ class RateLimiter:
71
+ """Drop-in replacement for agentpayments_python.ratelimit.RateLimiter."""
72
+
73
+ def __init__(self, redis_client, window: int = RATE_LIMIT_WINDOW, max_hits: int = RATE_LIMIT_MAX, key_prefix: str = "agp:rl:"):
74
+ self._redis = redis_client
75
+ self._window = window
76
+ self._max = max_hits
77
+ self._prefix = key_prefix
78
+
79
+ def check(self, key: str) -> bool:
80
+ """Returns True if the request should be allowed, False if rate-limited."""
81
+ try:
82
+ count = self._redis.eval(_INCR_SCRIPT, 1, f"{self._prefix}{key}", self._window)
83
+ return int(count) <= self._max
84
+ except Exception as exc:
85
+ logger.error("[agentpayments] RedisStore.RateLimiter error: %s", exc)
86
+ return True # fail open — don't block legitimate traffic
87
+
88
+
89
+ class PaymentCache:
90
+ """Drop-in replacement for the module-level _payment_cache in solana.py."""
91
+
92
+ def __init__(self, redis_client, key_prefix: str = "agp:pay:"):
93
+ self._redis = redis_client
94
+ self._prefix = key_prefix
95
+
96
+ def get(self, agent_key: str):
97
+ """Returns True, False, or None (not cached / expired / error)."""
98
+ try:
99
+ val = self._redis.get(f"{self._prefix}{agent_key}")
100
+ if val is None:
101
+ return None
102
+ if isinstance(val, bytes):
103
+ val = val.decode()
104
+ return val == "1"
105
+ except Exception as exc:
106
+ logger.error("[agentpayments] RedisStore.PaymentCache.get error: %s", exc)
107
+ return None
108
+
109
+ def set(self, agent_key: str, value: bool, ttl: int) -> None:
110
+ """ttl is in seconds, matching solana.py's _PaymentCache.set signature."""
111
+ try:
112
+ self._redis.set(f"{self._prefix}{agent_key}", "1" if value else "0", ex=max(1, int(ttl)))
113
+ except Exception as exc:
114
+ logger.error("[agentpayments] RedisStore.PaymentCache.set error: %s", exc)
115
+
116
+
117
+ def create_redis_store(redis_client) -> dict:
118
+ """
119
+ Convenience factory: create pre-configured rate limiters matching the
120
+ built-in defaults (challenge verify 20/min, agent-key 10/min, challenge
121
+ issuance 30/min), plus a shared payment cache.
122
+ """
123
+ return {
124
+ "challenge_verify_rate_limiter": RateLimiter(redis_client, max_hits=RATE_LIMIT_MAX, key_prefix="agp:rl:cv:"),
125
+ "agent_key_rate_limiter": RateLimiter(redis_client, max_hits=AGENT_KEY_RATE_LIMIT_MAX, key_prefix="agp:rl:ak:"),
126
+ "challenge_issue_rate_limiter": RateLimiter(redis_client, max_hits=CHALLENGE_ISSUE_RATE_LIMIT_MAX, key_prefix="agp:rl:ci:"),
127
+ "payment_cache": PaymentCache(redis_client),
128
+ }
@@ -98,17 +98,23 @@ def is_valid_solana_address(address: str) -> bool:
98
98
  return bool(address and BASE58_RE.match(address))
99
99
 
100
100
 
101
- def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_mint: str, min_payment: float = MIN_PAYMENT, fee_info: dict | None = None) -> bool:
101
+ def verify_payment_on_chain(agent_key: str, wallet_address: str, rpc_url, usdc_mint: str, min_payment: float = MIN_PAYMENT, fee_info: dict | None = None, payment_cache=None) -> bool:
102
102
  """
103
103
  Verify payment on-chain. Returns true/false only — the stable, tested
104
104
  public API. See _scan_for_payment below for the amount-returning variant
105
105
  used internally for pricing-tier resolution.
106
+
107
+ payment_cache: optional cache object with the same interface as the
108
+ module-level default (get(key) -> True/False/None, set(key, value, ttl)).
109
+ Pass a agentpayments_python.redis_store.PaymentCache for multi-process
110
+ deployments where the default in-memory cache (per-process) is
111
+ ineffective. Defaults to the module-level singleton.
106
112
  """
107
- result = _scan_for_payment(agent_key, wallet_address, rpc_url, usdc_mint, min_payment=min_payment, fee_info=fee_info)
113
+ result = _scan_for_payment(agent_key, wallet_address, rpc_url, usdc_mint, min_payment=min_payment, fee_info=fee_info, payment_cache=payment_cache)
108
114
  return result["paid"]
109
115
 
110
116
 
111
- def _scan_for_payment(agent_key: str, wallet_address: str, rpc_url, usdc_mint: str, min_payment: float = MIN_PAYMENT, fee_info: dict | None = None) -> dict:
117
+ def _scan_for_payment(agent_key: str, wallet_address: str, rpc_url, usdc_mint: str, min_payment: float = MIN_PAYMENT, fee_info: dict | None = None, payment_cache=None) -> dict:
112
118
  """
113
119
  Scans the chain for a matching payment, same as verify_payment_on_chain,
114
120
  but also returns the actual amount paid (decimal USDC) so callers can
@@ -120,12 +126,13 @@ def _scan_for_payment(agent_key: str, wallet_address: str, rpc_url, usdc_mint: s
120
126
  the vendor payment must also carry a USDC transfer to fee_info["wallet"] of at
121
127
  least min_payment * rate_pct / 100, or the payment is treated as unverified.
122
128
  """
129
+ cache = payment_cache if payment_cache is not None else _payment_cache
123
130
  # Normalise to list so _rpc_call_with_fallback always gets a list.
124
131
  rpc_urls: list[str] = rpc_url if isinstance(rpc_url, list) else [rpc_url]
125
132
  min_payment_micro = round(min_payment * 1_000_000)
126
133
  not_paid = {"paid": False, "amount_paid": None}
127
134
 
128
- cached = _payment_cache.get(agent_key)
135
+ cached = cache.get(agent_key)
129
136
  if cached is True:
130
137
  return {"paid": True, "amount_paid": None} # cache doesn't retain the amount
131
138
  if cached is False:
@@ -229,10 +236,10 @@ def _scan_for_payment(agent_key: str, wallet_address: str, rpc_url, usdc_mint: s
229
236
  has_fee_payment = True
230
237
 
231
238
  if has_memo and has_payment and has_fee_payment:
232
- _payment_cache.set(agent_key, True, PAYMENT_CACHE_TTL)
239
+ cache.set(agent_key, True, PAYMENT_CACHE_TTL)
233
240
  return {"paid": True, "amount_paid": matched_amount_micro / 1_000_000}
234
241
  except Exception:
235
242
  logger.exception("[gate] Solana RPC error")
236
243
 
237
- _payment_cache.set(agent_key, False, NEGATIVE_CACHE_TTL)
244
+ cache.set(agent_key, False, NEGATIVE_CACHE_TTL)
238
245
  return not_paid
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentpayments-python
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: AgentPayments gate for Python web frameworks — charge AI agents USDC on Solana before they can access your API
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/adambrzosko/AgentPayments
@@ -123,6 +123,9 @@ register_agentpayments(
123
123
  | `debug` | No | `True` | `True` = devnet. `False` = mainnet + strict mode. |
124
124
  | `api_key` | No | `None` | AgentPayments hosted-platform API key (`ap_live_...`). When set, agent keys are issued and metered via the platform instead of self-signed locally. See **Hosted Platform Mode** below. |
125
125
  | `platform_url` | No | AgentPayments-hosted URL | Override for a self-hosted platform API. |
126
+ | `agent_key_rate_limiter` | No | Built-in in-memory (10/min/IP) | Pluggable rate limiter for the agent-key payment-verification path. Pass a Redis-backed one for multi-process deployments — see **Multi-Process Deployments** below. |
127
+ | `challenge_issue_rate_limiter` | No | Built-in in-memory (30/min/IP) | Pluggable rate limiter for browser challenge-page issuance. |
128
+ | `payment_cache` | No | Module-level in-memory singleton | Pluggable payment-verification cache (10-min positive / 30s negative TTL by default). |
126
129
 
127
130
  Django reads these from `settings.*` (e.g., `settings.CHALLENGE_SECRET`, `settings.AGENTPAYMENTS_API_KEY`). FastAPI and Flask accept them as constructor arguments.
128
131
 
@@ -164,6 +167,38 @@ AGENTPAYMENTS_ROUTES = [...]
164
167
 
165
168
  **Revocation**: `MemoryGrantStore`/`FileGrantStore` (`agentpayments_python.grant_store`) both gained a `revoke(agent_key)` method — call it from your own admin view to cut off a specific paid key early. The adapters need no changes to respect this: `has()` already returns `False` for a revoked (or expired) grant. A grants file written by an older SDK version (a plain JSON array of key strings) is still read correctly as a set of permanent grants.
166
169
 
170
+ ## Multi-Process Deployments (Redis)
171
+
172
+ The built-in rate limiters and payment cache are in-memory and **per-process** — with `gunicorn -w 4`, each of the 4 workers enforces its own independent 10-req/min agent-key limit (40/min in aggregate) and caches payment results separately, so a payment verified by one worker isn't recognized by another until its own cache/rate-limit state catches up. For any multi-process deployment, plug in a Redis-backed store instead:
173
+
174
+ ```python
175
+ import redis
176
+ from agentpayments_python.redis_store import create_redis_store
177
+
178
+ r = redis.Redis.from_url(os.environ["REDIS_URL"])
179
+ store = create_redis_store(r)
180
+
181
+ # FastAPI / Flask
182
+ register_agentpayments(app, ..., # or AgentPaymentsASGIMiddleware(...)
183
+ agent_key_rate_limiter=store["agent_key_rate_limiter"],
184
+ challenge_issue_rate_limiter=store["challenge_issue_rate_limiter"],
185
+ payment_cache=store["payment_cache"],
186
+ )
187
+
188
+ # Django settings.py
189
+ AGENTPAYMENTS_AGENT_KEY_RATE_LIMITER = store["agent_key_rate_limiter"]
190
+ AGENTPAYMENTS_CHALLENGE_ISSUE_RATE_LIMITER = store["challenge_issue_rate_limiter"]
191
+ AGENTPAYMENTS_PAYMENT_CACHE = store["payment_cache"]
192
+
193
+ # The /__challenge/verify endpoint has its own rate limiter, passed separately
194
+ # since it's a standalone route/view rather than the main middleware:
195
+ # FastAPI: challenge_verify_endpoint(request, challenge_secret=..., rate_limiter=store["challenge_verify_rate_limiter"])
196
+ # Django: AGENTPAYMENTS_CHALLENGE_VERIFY_RATE_LIMITER = store["challenge_verify_rate_limiter"]
197
+ # Flask: register_agentpayments(app, ..., challenge_verify_rate_limiter=store["challenge_verify_rate_limiter"])
198
+ ```
199
+
200
+ `redis_store.py` has no hard dependency on the `redis` package — it's duck-typed against any client exposing `eval(script, numkeys, *args)`, `get(key)`, and `set(key, value, ex=ttl)` (redis-py's `Redis` client satisfies this directly), mirroring `sdk/node/redis-store.js`. Rate limiting fails open on a Redis error (never blocks legitimate traffic); the payment cache fails to a miss (falls through to a normal chain scan). All four are optional — anything left unset keeps using the default in-memory implementation.
201
+
167
202
  ## Security Features
168
203
 
169
204
  - **Timing-safe HMAC comparison** — uses `hmac.compare_digest()` for all signature checks
@@ -190,6 +225,7 @@ agentpayments_python/
190
225
  ratelimit.py Shared IP-based rate limiter
191
226
  grant_store.py Durable paid-key persistence (expiry, revocation)
192
227
  pricing.py Pricing-tier / access-duration / per-route resolution
228
+ redis_store.py Redis-backed rate limiter + payment cache (multi-process)
193
229
  ```
194
230
 
195
231
  ## Notes
@@ -5,17 +5,18 @@ agentpayments_python/cookies.py,sha256=0xO2_e4R4FpOprBcGWqY84E_eJppgJCkMGLCElZOQ
5
5
  agentpayments_python/crawler.py,sha256=G06nF6qFmyA5nDvMW18rfh44JxxJMKc2GCO0DXiZuoY,2514
6
6
  agentpayments_python/crypto.py,sha256=81xNcH1E468v26BimEQpK6n42bkqyz4q6eXhLLqQ6QY,1315
7
7
  agentpayments_python/detection.py,sha256=5KRaVdlMjOUaE167WTQyDXWIwF6sRFZ1DGZ2eqtoBAQ,1938
8
- agentpayments_python/django_adapter.py,sha256=D8dq0TUoZUN4u5g9VCEkqDzFYnwIHM_FRYzM9QholFI,15303
9
- agentpayments_python/fastapi_adapter.py,sha256=0L4PBqFb2A4UbNp7tLLQ3AEJC_tJAC9nAxM_sdhBboc,14573
10
- agentpayments_python/flask_adapter.py,sha256=i2hbB8Jf5qLmv_uLR7tGoNRR7Rls8xBnrIHzPqnJnYI,12733
8
+ agentpayments_python/django_adapter.py,sha256=pZIq62sC_MYcfP_fgKvg5Fs_F4ismTct5I7HmfWxZVQ,16317
9
+ agentpayments_python/fastapi_adapter.py,sha256=3dvPg_btZCxAws5xBIJ_hgbXFYuef8H2PGo5aXtCRwg,15370
10
+ agentpayments_python/flask_adapter.py,sha256=s6IOKE5gldgPXIlRwvT2fN_etPZr43NpGaSSZtDBOMg,13509
11
11
  agentpayments_python/grant_store.py,sha256=ewYAxKocgfJE_txZp1SDnML066QP4BBB6V9vXIo47eE,5057
12
12
  agentpayments_python/platform_client.py,sha256=T-fSzxsABXVJ9nfrqL370GboV2-hdJaA-ploOw0uOd8,5108
13
13
  agentpayments_python/pricing.py,sha256=3AujxsDlP5pMBals4FnoPdfOYBe1VPLREo_IJtJVxwg,2910
14
14
  agentpayments_python/ratelimit.py,sha256=Q6uxFRbYbf7D8qykZ9ieVaSCU8FkXFYPS59XmkrnE_k,1580
15
- agentpayments_python/solana.py,sha256=hRJ6V_rcxLgcw68XdNTqE_ShCIwpBXljjptj2IylPtw,11335
15
+ agentpayments_python/redis_store.py,sha256=2WSPBJYi8qp7B_yhqYX1bemfjbYn8OrH_ww9qpRw7Bc,5145
16
+ agentpayments_python/solana.py,sha256=SE6psJlveTZJEsY6I2Q1Dttp1cqdzuzsqM_yeer5toU,11802
16
17
  agentpayments_python/x402.py,sha256=PS5U_dWX1CyRMTxxAYnyuzb-q4mRWvXe1Pys3y-CvEg,5527
17
- agentpayments_python-0.2.0.dist-info/licenses/LICENSE,sha256=PMKDe4kanHRVI7QQezG4GeluZkMljhVO-tFSFNUiYS4,1069
18
- agentpayments_python-0.2.0.dist-info/METADATA,sha256=0uSQF-ytcrOf96T2kdg2tz3TmfXxcxBIUNS6mPPj_UY,9317
19
- agentpayments_python-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
20
- agentpayments_python-0.2.0.dist-info/top_level.txt,sha256=DxGMGvTYQVdoBQ6pBbbsJXy5BZhbSjNGF_uSO3git4w,21
21
- agentpayments_python-0.2.0.dist-info/RECORD,,
18
+ agentpayments_python-0.3.0.dist-info/licenses/LICENSE,sha256=PMKDe4kanHRVI7QQezG4GeluZkMljhVO-tFSFNUiYS4,1069
19
+ agentpayments_python-0.3.0.dist-info/METADATA,sha256=OXACM_sGdSQctXJuB-0hqhQziHG5TsA7jBQvE_zJTvc,12066
20
+ agentpayments_python-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
21
+ agentpayments_python-0.3.0.dist-info/top_level.txt,sha256=DxGMGvTYQVdoBQ6pBbbsJXy5BZhbSjNGF_uSO3git4w,21
22
+ agentpayments_python-0.3.0.dist-info/RECORD,,