Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

A python library for integrating with PhonePe APIs.

## v3.0.0 - Breaking changes

- **Retry mechanism removed.** The SDK no longer retries any HTTP call (including GET). The
`should_retry` constructor parameter has been removed from `StandardCheckoutClient`,
`CustomCheckoutClient`, and `SubscriptionClient` - passing it now raises a `TypeError`.
- **Client construction can now raise.** The SDK fetches its OAuth token immediately at
construction (a single, non-blocking attempt) instead of waiting for the first API call.
Genuine configuration problems (e.g. invalid credentials) now fail fast and
`get_instance(...)`/the constructor raises immediately, where previously construction always
succeeded regardless of credential validity. See the [Quick start](#quick-start) note below for
details - transient failures do NOT raise or block; they're retried automatically in the
background instead.
- **New:** configurable connection pooling/timeouts via `HttpClientConfig` (see
[Connection pool & timeout tuning](#connection-pool--timeout-tuning)) and a `close()` method on
every client to release resources cleanly.

## Installation

Requires `python 3.9` or later
Expand Down Expand Up @@ -32,6 +48,15 @@ standard_phonepe_client = StandardCheckoutClient.get_instance(client_id=client_i
env=env)
```

> **Note:** Client construction fetches an OAuth token immediately (a single, non-blocking
> attempt) rather than waiting for the first API call. A genuine configuration problem (e.g.
> invalid credentials) fails fast and `get_instance(...)`/the constructor raises immediately; a
> transient failure (network blip, 5xx, rate-limiting) does NOT block construction or raise - it's
> retried automatically in the background instead. Once constructed, the token is kept fresh
> automatically in the background for the lifetime of the client - see
> [Connection pool & timeout tuning](#connection-pool--timeout-tuning) below for `close()` and
> other tunable behavior.

### Initiate an order using Checkout Page

To init a pay request, we make a request object using `StandardCheckoutPayRequest.build_request` [build_request](#standard-checkout-pay-request-builder).
Expand Down Expand Up @@ -70,6 +95,73 @@ You will get the data [OrderStatusResponse](#order-status-response) object.

For more details, please visit: https://developer.phonepe.com

## Connection pool & timeout tuning

Every client accepts an optional `http_client_config` argument on both its constructor and `get_instance(...)`, letting
you tune the underlying HTTP connection pool and timeouts per merchant/client instance:

```python
from phonepe.sdk.pg.common.configs.http_client_config import HttpClientConfig
from phonepe.sdk.pg.payments.v2.standard_checkout_client import StandardCheckoutClient
from phonepe.sdk.pg.env import Env

http_client_config = HttpClientConfig(
pool_size=10, # max pooled (kept-alive) connections per host
keep_alive_seconds=60, # proactively recycle connections idle longer than this
connect_timeout_seconds=3, # max time to establish the TCP/TLS connection
read_timeout_seconds=30, # max time to wait for a response once the request is sent
)

standard_phonepe_client = StandardCheckoutClient.get_instance(
client_id=client_id,
client_secret=client_secret,
client_version=client_version,
env=env,
http_client_config=http_client_config,
)
```

If `http_client_config` is omitted, the SDK uses the defaults shown above (`pool_size=10`,
`keep_alive_seconds=60`, `connect_timeout_seconds=3`, `read_timeout_seconds=30`).

**Why these four settings trade off against each other:**

- **`pool_size`** caps how many connections are kept alive per host. A merchant sending many
concurrent requests benefits from a larger pool so requests don't queue up waiting for a free
connection; a merchant sending only the occasional request (e.g. one every several seconds)
gains nothing from a large pool - a small value (2-4) is enough, since most of those connections
would otherwise sit idle.
- **`keep_alive_seconds`** bounds how long a pooled connection can sit idle before the SDK
proactively closes and replaces it with a fresh one, rather than risking handing a request a
connection that a server/load balancer has already silently closed while idle (a scenario
confirmed via repro testing against PhonePe's production environment). This is enforced both
the moment a connection is next reused for a request *and* independently by a background
sweep thread that periodically closes idle connections directly, so staleness is bounded even
during a period with no request traffic at all.
- **`connect_timeout_seconds`** / **`read_timeout_seconds`** bound how long a single request is
allowed to take establishing a connection vs. waiting for a response. A merchant with fast,
reliable infrastructure can tighten these to fail faster on genuine problems; a merchant on
slower/less reliable infrastructure (or calling latency-sensitive endpoints like autoPay APIs)
may need to raise `read_timeout_seconds` to avoid timing out on otherwise-successful, just-slow
responses.

### Releasing resources with `close()`

Every client exposes a `close()` method that releases pooled HTTP connections and stops the
SDK's background threads (token refresh, connection recycling, event publishing).

**Calling `close()` is optional.** All of these are daemon threads, so a long-lived server that
creates its client once and never closes it works exactly as before and still exits cleanly.
Call `close()` only when you want a deterministic, immediate release - short-lived processes
(tests, scripts, serverless invocations), or when you want to discard a client instance:

```python
standard_phonepe_client.close()
```

`close()` also removes the instance from the `get_instance()` cache, so a later `get_instance()`
call with the same arguments builds a fresh client instead of returning the closed one. It is
safe to call multiple times, and it waits for any in-flight event flush to finish.

## License

Expand Down
2 changes: 1 addition & 1 deletion phonepe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@

"""Package for integration with PhonePe APIs"""

__version__ = "2.3.0"
__version__ = "3.0.0"
148 changes: 123 additions & 25 deletions phonepe/sdk/pg/common/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@

import json
import logging
import threading
from dataclasses import dataclass

import phonepe
from phonepe.sdk.pg.common.configs.credential_config import CredentialConfig
from phonepe.sdk.pg.common.configs.http_client_config import HttpClientConfig
from phonepe.sdk.pg.common.constants.headers import (
SOURCE,
SOURCE_VERSION,
Expand Down Expand Up @@ -51,34 +53,102 @@ def __init__(
client_version: int,
env: Env,
should_publish_events: bool = True,
should_retry: bool = True,
http_client_config: HttpClientConfig = None,
):
self.env = env
self.credential_config = CredentialConfig(
client_id=client_id,
client_secret=client_secret,
client_version=client_version,
)

self._http_command = BaseHttpCommand(get_pg_base_url(self.env))
self._pci_http_command = BaseHttpCommand(get_pci_pg_base_url(self.env))
# Same HttpClientConfig applies to every host this client instance talks to (main pg,
# PCI, event ingestion, oauth) - one merchant traffic profile, consistently tuned.
self.http_client_config = http_client_config or HttpClientConfig()
self.should_publish_events = should_publish_events
self.should_retry = should_retry
self._event_publisher_factory = EventPublisherFactory(
event_sender=BaseHttpCommand(host_url=get_event_ingestion_base_url(env))
)
self.event_publisher = self._event_publisher_factory.get_event_publisher(
should_publish_events=should_publish_events
)
self._token_service = TokenService(
credential_config=self.credential_config,
env=self.env,
event_publisher=self.event_publisher,
should_retry=should_retry,
)
self.event_publisher.start_publishing_events(
auth_token_supplier=self._token_service.get_auth_token
)

# Track resources that need cleanup if a later step fails (e.g. TokenService's eager
# fetch raising on bad credentials) - otherwise components created earlier (each starts
# its own background thread) would leak since this partially-built instance is never
# returned to the caller and close() can never be called on it.
closables = []
try:
self._http_command = BaseHttpCommand(get_pg_base_url(self.env),
http_client_config=self.http_client_config)
closables.append(self._http_command)

self._pci_http_command = BaseHttpCommand(get_pci_pg_base_url(self.env),
http_client_config=self.http_client_config)
closables.append(self._pci_http_command)

self._event_publisher_factory = EventPublisherFactory(
event_sender=BaseHttpCommand(host_url=get_event_ingestion_base_url(env),
http_client_config=self.http_client_config)
)
closables.append(self._event_publisher_factory.event_sender)

self.event_publisher = self._event_publisher_factory.get_event_publisher(
should_publish_events=should_publish_events
)
closables.append(self.event_publisher)

self._token_service = TokenService(
credential_config=self.credential_config,
env=self.env,
event_publisher=self.event_publisher,
http_client_config=self.http_client_config,
)
closables.append(self._token_service)

self.event_publisher.start_publishing_events(
auth_token_supplier=self._token_service.get_auth_token
)
except Exception:
for closable in reversed(closables):
try:
closable.close()
except Exception:
logging.exception(
"Error while releasing a resource during cleanup of a failed client construction"
)
raise

@classmethod
def _get_key_lock(cls, cache_key):
"""Returns (creating if necessary) a lock scoped to this one cache_key, so building/
closing one merchant's client never blocks an unrelated merchant's get_instance()/
close() call on the same class. `cls._instance_lock` here only guards this tiny
dict-of-locks bookkeeping - it is held only briefly and NEVER across the actual
(potentially slow, network-bound) client construction/OAuth fetch or close() below."""
with cls._instance_lock:
key_lock = cls._key_locks.get(cache_key)
if key_lock is None:
key_lock = threading.Lock()
cls._key_locks[cache_key] = key_lock
return key_lock

@classmethod
def _get_or_build_cached_instance(cls, cache_key, build_and_register):
"""Thread-safe get-or-create for a subclass's get_instance() singleton cache
(cls._cached_instances, keyed by cache_key). Checks the cache lock-free first (the
common hit path), then re-checks under a lock SCOPED TO THIS cache_key before building -
otherwise two concurrent first-callers for the same key could each build and orphan a
full duplicate client (threads, pools, and a wasted OAuth fetch), with only one
surviving in the cache. Using a per-key lock (rather than one shared class-wide lock)
means a slow/hanging build for one merchant's credentials never blocks get_instance()/
close() calls for any OTHER merchant's cache_key.

`build_and_register` is called (with the per-key lock held) only on a genuine cache
miss; it must construct the new instance, store it in cls._cached_instances[cache_key],
and return it.
"""
cached = cls._cached_instances.get(cache_key)
if cached is not None:
return cached
with cls._get_key_lock(cache_key):
cached = cls._cached_instances.get(cache_key)
if cached is not None:
return cached
return build_and_register()

def _request_with_token_invalidation(
self,
Expand All @@ -90,10 +160,8 @@ def _request_with_token_invalidation(
data: dict = None,
http_command: "BaseHttpCommand" = None,
):
# On UnauthorizedAccess the token cache is invalidated so the next call
# fetches a fresh token. This method does NOT retry the request itself.
# If a retry is added in future, use `command` (not `self._http_command`)
# so PCI-scoped calls are not silently downgraded to the standard host.
# On UnauthorizedAccess the token cache is invalidated so the next call fetches a fresh
# token. This method does NOT retry the request itself.
command = http_command if http_command is not None else self._http_command
try:
response_data = command.request(
Expand All @@ -102,7 +170,6 @@ def _request_with_token_invalidation(
headers=merge_dict(self._prepare_headers(), headers),
path_params=path_params,
data=data,
should_retry=self.should_retry,
)
except UnauthorizedAccess as exception:
logging.info(f"Failed to authorize")
Expand All @@ -115,6 +182,37 @@ def _request_with_token_invalidation(
return None
return response_obj.from_dict(response_data.json())

def close(self):
"""Releases resources held by this client instance: pooled HTTP connections, the event
publisher's background scheduler, and the token service's background refresh thread.
Also evicts this instance from its class's get_instance() cache (if present), so a
later get_instance() call with the same arguments builds a fresh client instead of
returning this now-closed one. Safe to call multiple times."""
# The event publisher is stopped before the session it publishes through, otherwise an
# in-flight flush would re-create pooled connections on an already-closed sender.
# Each step is isolated so one failure can't leave the remaining components running.
for closer in (
self._evict_from_instance_cache,
self.event_publisher.close,
self._event_publisher_factory.event_sender.close,
self._http_command.close,
self._pci_http_command.close,
self._token_service.close,
):
try:
closer()
except Exception:
logging.exception("Error while releasing a resource during client close()")

def _evict_from_instance_cache(self):
cache_key = getattr(self, "_cache_key", None)
cached_instances = getattr(type(self), "_cached_instances", None)
if cache_key is None or cached_instances is None:
return
with type(self)._get_key_lock(cache_key):
if cached_instances.get(cache_key) is self:
del cached_instances[cache_key]

def _prepare_headers(self):
return {
SOURCE: INTEGRATION,
Expand Down
70 changes: 70 additions & 0 deletions phonepe/sdk/pg/common/configs/http_client_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Copyright 2025 PhonePe Private Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from dataclasses import dataclass


@dataclass(frozen=True)
class HttpClientConfig:
"""Tunable HTTP connection-pool and timeout settings for a PhonePe SDK client instance.

These four settings travel together and trade off against each other based on a merchant's
traffic profile:

- A high-throughput merchant (many concurrent requests) typically wants a larger
`pool_size` so requests don't queue up waiting for a free pooled connection, and may
want a smaller `read_timeout_seconds` since their own infrastructure is fast and a slow
response is more likely a genuine problem worth failing fast on.
- A low-throughput merchant (e.g. one request every several seconds) or one running on
slower infrastructure typically needs only a small `pool_size` (2-4 is often enough -
the default of 10 would sit mostly idle) but may want a larger `read_timeout_seconds` to
tolerate their own slower network/processing before giving up on a response.

See the SDK README's "Connection pool & timeout tuning" section for worked examples.

Attributes
----------
pool_size: int
Maximum number of pooled (kept-alive) connections per host. Default 10.
keep_alive_seconds: float
Maximum time a pooled connection is allowed to sit idle before the SDK proactively
closes and replaces it with a fresh one, rather than risking handing a request a
connection the server/load-balancer may have already silently closed. Enforced two
ways: lazily, the moment an aged-out connection is next checked out for a request, and
proactively, via a background sweep thread (per client instance) that periodically
closes idle connections directly - roughly every keep_alive_seconds / 2 - so a
connection is never left waiting much longer than ~1.5x keep_alive_seconds before being
recycled, even during a long period with no request traffic at all. Default 60 seconds.
connect_timeout_seconds: float
Maximum time to wait while establishing the TCP/TLS connection. Default 3 seconds.
read_timeout_seconds: float
Maximum time to wait for the server to send a response once the request has been sent.
Default 30 seconds (generous enough to accommodate slower endpoints such as autoPay
APIs).
"""

pool_size: int = 10
keep_alive_seconds: float = 60
connect_timeout_seconds: float = 3
read_timeout_seconds: float = 30

def __post_init__(self):
if self.pool_size <= 0:
raise ValueError(f"pool_size must be positive, got {self.pool_size}")
if self.keep_alive_seconds <= 0:
raise ValueError(f"keep_alive_seconds must be positive, got {self.keep_alive_seconds}")
if self.connect_timeout_seconds <= 0:
raise ValueError(f"connect_timeout_seconds must be positive, got {self.connect_timeout_seconds}")
if self.read_timeout_seconds <= 0:
raise ValueError(f"read_timeout_seconds must be positive, got {self.read_timeout_seconds}")
5 changes: 5 additions & 0 deletions phonepe/sdk/pg/common/events/publisher/event_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@ def send(self, event: BaseEvent):

def start_publishing_events(self, auth_token_supplier: Callable):
pass

def close(self):
"""Releases any background resources held by this publisher. No-op by default - the
no-op EventPublisher used when should_publish_events=False holds nothing to release."""
pass
Loading