Skip to content

Native async clients (0.1.0a1 alpha)

The source tree provides AsyncAlphaSMSClient, AsyncBulkSMSBDClient, AsyncGreenWebClient, AsyncPathaoClient, AsyncRedXClient and AsyncSteadfastClient, exported from jukto. These additions have not been published to PyPI by these tasks. AsyncSSLCommerzClient additionally supports the scoped hosted payment flow; bKash/Nagad remain planned.

Use the same constructor configuration as the sync client, with an optional borrowed httpx.AsyncClient. Await send_sms/submit_sms, create_order/ create_shipment, check_status/check_shipment_status, and Pathao's get_token and get_stores. Results, input validation, error classifications, secure defaults and unresolved provider contracts match sync clients. Async interfaces are AsyncBaseSMSProvider and AsyncBaseLogisticsProvider; existing sync interfaces and calls remain unchanged. There is no hidden event-loop bridge or thread offload.

from jukto import AsyncAlphaSMSClient

async def submit(key: str):
    async with AsyncAlphaSMSClient(key) as provider:
        return await provider.submit_sms("01700000000", "Synthetic example")

The phone and message above are synthetic; calling this function with real credentials sends a real request. Routine tests use httpx.MockTransport only.

Lifetime and cancellation

These clients support asyncio, scoped to one event loop and worker. Trio is not supported by Jukto's asyncio locks and cleanup even though HTTPX supports it. Do not share providers across loops, processes or sync threads. Reuse one provider for a worker's lifespan; avoid creating pools inside hot loops. async with or await provider.aclose() closes an owned pool. Borrowed clients remain open and must be closed by their owner. Cleanup waits for the owned client's aclose() even if the cleanup caller is repeatedly cancelled, then propagates cancellation. A cleanup error remains visible. Finish or cancel and await in-flight tasks before closing the provider; concurrent shutdown with active requests is unsupported.

asyncio.CancelledError propagates unchanged. Cancellation during a refresh leaves the previously cached token state intact, releases the token lock and lets another caller refresh. Fully validated tokens publish atomically without a suspension. An upstream server may nevertheless rotate a refresh token before cancellation is observed locally. This cannot be rolled back; invalid-grant fallback follows the same narrow rule as the sync client. The cache and single-flight lock are per provider instance, not distributed across workers.

Cancellation after an SMS/shipment request begins may leave a completed upstream operation. It does not establish failure and does not automatically retry. Persist an application operation reference before dispatch and reconcile uncertain outcomes before resubmission. The SDK does not provide durable storage or provider enforced idempotency. Pathao replays a read once on 401; order replay still requires retry_unauthorized_orders=True and remains an explicit, potentially duplicating choice. No generic retries were added.

FastAPI: native requests, bounded concurrency

This application creates one provider at lifespan startup, injects it through a request dependency, awaits native HTTP and closes it at shutdown. Set JUKTO_ALPHA_API_KEY, then run uvicorn examples.async_fastapi_app:app from the checkout after installing the examples extra. Missing configuration fails startup. Existing sync FastAPI examples remain valid.

"""One native async provider per asyncio worker; bound in-flight submissions."""
from __future__ import annotations
import asyncio
import os
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Callable
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from jukto import AsyncAlphaSMSClient, AsyncBaseSMSProvider, JuktoConfigurationError, JuktoError, SMSBatchError
from examples.application import safe_result


def configured_provider() -> AsyncBaseSMSProvider:
    key = os.environ.get('JUKTO_ALPHA_API_KEY', '')
    if not key.strip():
        raise JuktoConfigurationError('Configure JUKTO_ALPHA_API_KEY before serving requests')
    return AsyncAlphaSMSClient(key, timeout=10.0)


def get_provider(request: Request) -> AsyncBaseSMSProvider:
    return request.app.state.sms_provider


def create_app(factory: Callable[[], AsyncBaseSMSProvider] = configured_provider,
               max_in_flight: int = 10) -> FastAPI:
    if type(max_in_flight) is not int or max_in_flight <= 0:
        raise ValueError('max_in_flight must be a positive integer')

    @asynccontextmanager
    async def lifespan(app: FastAPI) -> AsyncIterator[None]:
        async with factory() as provider:
            app.state.sms_provider = provider
            app.state.sms_limit = asyncio.Semaphore(max_in_flight)
            yield

    app = FastAPI(lifespan=lifespan)

    @app.post('/sms')
    async def submit(request: Request, payload: dict[str, Any],
                     provider: AsyncBaseSMSProvider = Depends(get_provider)) -> JSONResponse:
        if any(not isinstance(payload.get(key), str) or not payload[key].strip() for key in ('phone', 'message')):
            return JSONResponse({'error': 'phone and message must be nonempty strings'}, status_code=400)
        try:
            async with request.app.state.sms_limit:
                result = await provider.submit_sms(payload['phone'], payload['message'])
        except SMSBatchError as error:
            if error.normalized_result is not None:
                return JSONResponse(safe_result(error.normalized_result), status_code=202)
            return JSONResponse({'error': 'batch_result_unavailable', 'status': 'unknown',
                                 'reconciliation_required': True}, status_code=502)
        except JuktoError as error:
            return JSONResponse({'error': 'provider_error', 'status': error.outcome.value,
                                 'reconciliation_required': error.reconciliation_required}, status_code=502)
        return JSONResponse(safe_result(result), status_code=202)

    return app


app = create_app()

max_in_flight limits submissions per worker; HTTPX limits= separately controls pool connections, and timeout= bounds HTTP phases. A semaphore does not bound queued requests, aggregate limits across processes, provide rate limiting or cap total latency. Add ingress limits, queue deadlines, authentication and durable reconciliation for production. Do not retry the entire GreenWeb batch after a partial result. Sample error responses omit raw data and recipient details.

Evidence and verification

Implementation follows HTTPX async client and lifecycle guidance (checked 2026-10-10). No new provider code, endpoint, success or idempotency claim was introduced; the provider matrix applies equally to both interfaces. Offline tests compare serialized requests, normalized results and error metadata across all six providers, exercise AlphaSMS's recorded official codes, malformed responses, partial outcomes, concurrent refresh, cancellation, client ownership and bounded FastAPI requests. Mocks establish SDK behavior, not live provider compatibility.

python -m pytest tests/test_async_providers.py --no-cov