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
99 changes: 99 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

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) - retrying
is unsafe for non-idempotent calls like pay/refund, since the original request may have already
been processed server-side even if the response was lost. 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 +50,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 +97,78 @@ 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 (`StandardCheckoutClient`, `CustomCheckoutClient`, `SubscriptionClient`) 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.

**Worked examples:**

- **High-throughput merchant** (e.g. many concurrent payment/status requests per second, on solid
infrastructure): increase `pool_size` (e.g. 20-50) so concurrent requests aren't blocked waiting
for a free connection, and consider lowering `read_timeout_seconds` (e.g. 10-15s) since a slow
response is more likely a genuine problem worth failing fast on.
- **Low-throughput / slower-infrastructure merchant** (e.g. one request every several seconds,
or calling from a network with higher latency): a small `pool_size` (2-4) is plenty - a large
pool would mostly sit idle - but raise `read_timeout_seconds` (e.g. 45-60s) to tolerate your
own slower network/processing before giving up on an otherwise-successful response.

### Releasing resources with `close()`

Every client exposes a `close()` method that releases pooled HTTP connections and stops the
background token-refresh thread (see below). This is a daemon thread, so it doesn't prevent your
process from exiting even if you never call `close()` - but short-lived processes (tests, scripts,
serverless invocations) that want a clean, immediate shutdown should call it explicitly:

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

## 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"
34 changes: 24 additions & 10 deletions phonepe/sdk/pg/common/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

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,21 +52,25 @@ 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,
)
# 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._http_command = BaseHttpCommand(get_pg_base_url(self.env))
self._pci_http_command = BaseHttpCommand(get_pci_pg_base_url(self.env))
self._http_command = BaseHttpCommand(get_pg_base_url(self.env), http_client_config=self.http_client_config)
self._pci_http_command = BaseHttpCommand(get_pci_pg_base_url(self.env),
http_client_config=self.http_client_config)
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))
event_sender=BaseHttpCommand(host_url=get_event_ingestion_base_url(env),
http_client_config=self.http_client_config)
)
self.event_publisher = self._event_publisher_factory.get_event_publisher(
should_publish_events=should_publish_events
Expand All @@ -74,7 +79,7 @@ def __init__(
credential_config=self.credential_config,
env=self.env,
event_publisher=self.event_publisher,
should_retry=should_retry,
http_client_config=self.http_client_config,
)
self.event_publisher.start_publishing_events(
auth_token_supplier=self._token_service.get_auth_token
Expand All @@ -91,9 +96,9 @@ def _request_with_token_invalidation(
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.
# fetches a fresh token. This method does NOT retry the request itself: retrying is
# unsafe for non-idempotent calls (e.g. pay, refund) since the original request may
# already have been processed server-side even if the response was lost.
command = http_command if http_command is not None else self._http_command
try:
response_data = command.request(
Expand All @@ -102,7 +107,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 +119,16 @@ 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 and the
token service's background refresh thread (if running). Safe to call multiple times.
Useful for short-lived processes (tests, scripts, serverless invocations) that want to
shut down cleanly instead of relying on daemon threads/process exit."""
self._http_command.close()
self._pci_http_command.close()
self._event_publisher_factory.event_sender.close()
self._token_service.close()

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}")
Loading