Skip to content

find_all on child resources sends no Authorization header, so twelve methods return 401 #410

Description

@toolshedlabs-hash

Summary

On lago-python-client 1.50.0, FindAllChildrenCommandMixin.find_all sends its GET without an
Authorization header, so the twelve public methods that inherit it return 401 against the real
API. Every other mixin in the file passes the header.

Nothing leaks. The header is absent, not misrouted, so the calls simply fail.

PR #385 reported this in March and proposed the two line fix. It was closed unmerged on
2026-04-05. This issue exists because the defect is still in the current PyPI release, and to
add the reproduction and the scope that the PR did not include.

Reproduction

Clean venv, lago-python-client==1.50.0 from PyPI, Python 3.14.0. A loopback HTTP server
records the Authorization header of each request, and one Client makes all seven calls.

client = Client(api_key="PROBE-LAGO-API-KEY", api_url=f"http://127.0.0.1:{PORT}")
call request Authorization
customer_wallets.find_all("cust_1") GET /api/v1/customers/cust_1/wallets absent
customer_invoices.find_all("cust_1") GET /api/v1/customers/cust_1/invoices absent
plan_charges.find_all("plan_1") GET /api/v1/plans/plan_1/charges absent
subscription_charges.find_all("sub_1") GET /api/v1/subscriptions/sub_1/charges absent
customers.find_all() GET /api/v1/customers Bearer PROBE-LAGO-API-KEY
plan_charges.find("plan_1", "charge_1") GET /api/v1/plans/plan_1/charges/charge_1 Bearer PROBE-LAGO-API-KEY
customer_payment_methods.destroy("cust_1", "pm_1") DELETE /api/v1/customers/cust_1/payment_methods/pm_1 Bearer PROBE-LAGO-API-KEY

The last three are controls. Row six uses the same client object as row three, so the difference
is the method and not the client. Row seven is a hand written method in
customers/payment_methods_client.py, the same file as one of the affected classes, and it
passes the header explicitly.

Where it is

Twelve of the thirteen mixins in mixins.py build the request like this:

mixins.py:134    api_response: Response = send_get_request(
mixins.py:135        url=make_url(...),
mixins.py:140        headers=make_headers(api_key=self.api_key),
mixins.py:141        timeout=timeout,
mixins.py:142        rate_limit_retry_config=self.rate_limit_retry_config,
mixins.py:143    )

FindAllChildrenCommandMixin.find_all does not:

mixins.py:156    def find_all(
mixins.py:157        self: _ClientMixin[_M],
mixins.py:158        resource_id: str,
mixins.py:159        options: QueryPairs = None,
mixins.py:160        timetour: Optional[httpx.Timeout] = None,
mixins.py:161    ) -> Mapping[str, Any]:
...
mixins.py:166        api_response: Response = send_get_request(
mixins.py:167            url=make_url(...),
mixins.py:176            rate_limit_retry_config=self.rate_limit_retry_config,
mixins.py:177        )

send_get_request is _create_retry_wrapper(httpx.get) in services/request.py:127, which
forwards kwargs to httpx and adds no default headers. Nothing downstream puts it back.

The parameter on line 160 is also spelled timetour, and it is never forwarded. Since
send_get_request wraps httpx.get, these calls fall back to httpx's own 5 second default
rather than the DEFAULT_TIMEOUT of 10 seconds that mixins.py:35 sets for everything else.

Scope

Twelve public methods inherit it:

customer_applied_coupons, customer_credit_notes, customer_invoices,
customer_payment_methods, customer_payment_requests, customer_payments,
customer_subscriptions, customer_wallets, plan_charges, plan_fixed_charges,
subscription_charges, subscription_fixed_charges, each via .find_all(...).

History

The mixin was created already missing the header, in commit 9cdc20e (2025-09-15,
"feat(customers): Add new routes scoped to cusomters", #338). The diff introduces both the
missing headers= and the timetour spelling.

Commit ac96f50 (2026-04-13, "feat: add HTTP 429 rate limit retry support", #390) edited this
same send_get_request(...) call to add rate_limit_retry_config=. The header was not added
then either.

Suggested fix

The same two lines PR #385 proposed:

api_response: Response = send_get_request(
    url=make_url(...),
    headers=make_headers(api_key=self.api_key),
    timeout=timetour,
    rate_limit_retry_config=self.rate_limit_retry_config,
)

Renaming timetour to timeout would match the other twelve mixins, though it is a signature
change for anyone passing it by keyword. Keeping both, with timetour deprecated, avoids that.

Reopening #385 would be the shortest path. Happy to open a fresh PR instead if that is easier.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions