Skip to content

fix(python): reuse ssl context in the python sync client - #607

Merged
rhamzeh merged 2 commits into
openfga:mainfrom
wadells:ssl-context-reuse
Sep 12, 2025
Merged

fix(python): reuse ssl context in the python sync client#607
rhamzeh merged 2 commits into
openfga:mainfrom
wadells:ssl-context-reuse

Conversation

@wadells

@wadells wadells commented Sep 5, 2025

Copy link
Copy Markdown
Contributor

Description

This brings sync client ssl context handling in line with the async client. Importantly, openssl has a pretty significant performance regression in creating ssl contexts v3.0+ that can be mitigated by paying the context creation tax once, instead of for every request.

What problem is being solved?

Performance penalty observed when we upgraded from debian bullseye (openssl 1.1.1w) to debian bookworm (openssl 3.0.17). We saw a roughly 40ms performance penalty across our use of the sync client.

How is it being solved?

We remove an expensive call (ssl context creation) from each request, and instead do it once, upon client instantiation. This matches the behavior in the aio client.

What changes are made to solve it?

See above

Testing

TLDR: On the worst openssl version I tested, we saw a 40ms reduction in p50 FGA call time. This accounts for 75% of the total call time. 4x performance improvement!

p50 before: 53.9ms
p50 after:  13.5ms

I didn't write any tests on the FGA side. However I did do benchmarking using the following script.

fgabench.py
import os
import ssl
import time
import statistics
import sys

import openfga_sdk
from openfga_sdk.credentials import Credentials, CredentialConfiguration
from openfga_sdk.sync import OpenFgaClient as OpenFgaClient
from openfga_sdk.client.models import ClientCheckRequest


def benchmark(client, num_requests: int = 10) -> None:
    times: List[float] = []
    for i in range(1, num_requests + 1):
        options = {}
        body = ClientCheckRequest(
            user="account:1#admin",
            relation="editor",
            object="folder:{n}",
        )
        start = time.perf_counter()
        resp = client.check(body, options)
        end = time.perf_counter()
        times.append(end - start)
    return times


def main():
    secret = os.environ['OPENFGA_SECRET']
    credentials = Credentials(
        method='client_credentials',
        configuration=CredentialConfiguration(
            api_issuer= "<redacted>",
            api_audience= "<redacted>",
            client_id= "<redacted>",
            client_secret=secret
        )
    )
    configuration = openfga_sdk.ClientConfiguration(
        api_url = "<redacted>",
        store_id = "<redacted>",
        credentials = credentials,
    )
    if len(sys.argv) > 1:
        num_requests = sys.argv[1]
    else:
        num_requests = 100
    with OpenFgaClient(configuration) as client:
        times = benchmark(client, num_requests)

    print(f"OpenSSL: {ssl.OPENSSL_VERSION}")
    print(f"avg:     {statistics.mean(times) * 1000:>6.1f}ms")
    print(f"stddev:  {statistics.stdev(times) * 1000:>6.1f}ms")
    print(f"p0:      {min(times)* 1000:>6.1f}ms")
    print(f"p50:     {statistics.median(times) * 1000:>6.1f}ms")
    print(f"p90:     {statistics.quantiles(times, n=10)[8] * 1000:>6.1f}ms")
    print(f"p100:    {max(times) * 1000:>6.1f}ms")
    print(f"count:   {num_requests:>4}")


if __name__ == "__main__":
    main()

Here are the results tested on an amd64 t2.medium AWS instance in us-west-2.

  • openfga-sdk is the performance as of v0.9.4
  • sdk-patched is v0.9.4 with a monkey patched openfga_sdk.sync.rest.RESTClientObject.__init__ to use my new version
  • urllib is the default urllib.request.urlopen("https://api.us1.fga.dev/") behavior. Included as a reference point.
  • urllib+ctx is urllib with a reused ssl-context. Same fix, but without any of the potential complications of FGA client or server logic.
$ ./fgabench.sh
python:3.10.18-slim-bullseye
OpenSSL 1.1.1w  11 Sep 2023
metric  openfga-sdk sdk-patched      urllib  urllib+ctx
avg:         16.3ms      14.7ms      57.4ms       7.6ms
stddev:      26.2ms      21.3ms     501.2ms       4.3ms
p0:          10.2ms       9.8ms       5.6ms       5.1ms
p50:         12.7ms      12.4ms       7.3ms       7.1ms
p90:         15.2ms      13.8ms       8.2ms       9.1ms
p100:       265.1ms     224.3ms    5019.5ms      47.0ms
count:      100         100         100         100
python:3.10.18-slim-bookworm
OpenSSL 3.0.17 1 Jul 2025
metric  openfga-sdk sdk-patched      urllib  urllib+ctx
avg:         57.0ms      15.2ms      47.7ms       7.3ms
stddev:      29.3ms      17.6ms       1.3ms       1.0ms
p0:          51.6ms      10.9ms      45.6ms       5.6ms
p50:         53.9ms      13.5ms      47.6ms       7.6ms
p90:         55.8ms      15.0ms      48.8ms       8.4ms
p100:       347.1ms     189.0ms      56.6ms      10.2ms
count:      100         100         100         100
python:3.10.18-slim-trixie
OpenSSL 3.5.1 1 Jul 2025
metric  openfga-sdk sdk-patched      urllib  urllib+ctx
avg:         30.8ms      15.5ms      18.8ms       7.0ms
stddev:      22.9ms      20.7ms       3.7ms       0.9ms
p0:          22.9ms      10.5ms      16.4ms       5.3ms
p50:         26.6ms      13.3ms      18.7ms       7.3ms
p90:         33.7ms      15.3ms      19.5ms       8.1ms
p100:       243.3ms     219.0ms      52.3ms       9.3ms
count:      100         100         100         100

References

See openssl/openssl#17064 for a comprehensive discussion of the openssl performance issues.

Review Checklist

  • I have clicked on "allow edits by maintainers".
  • I have added documentation for new/changed functionality in this PR or in a PR to openfga.dev N/A
  • The correct base branch is being used, if not main
  • I have performed tests to validate that the change in functionality is working as expected

Summary by CodeRabbit

  • Refactor
    • Updated Python client TLS handling to use a reusable SSL context for both direct and proxy connections, improving reliability without changing the public API.
  • Bug Fixes
    • Mitigated performance issues seen with OpenSSL 3.x by reusing the TLS context.
    • Preserved expected behavior when SSL verification is disabled (hostname and certificate checks are turned off).

This brings ssl context handling in line with the async client.
Importantly, openssl has a pretty signifigant performance regression
in creating ssl contexts v3.0+ that is mitigated by paying the context
creation tax once, instead of for every request.

Based on my testing, this reduces the openssl v3 performance penalty
from ~200ms per connection to 9ms per connection.
@wadells
wadells requested a review from a team as a code owner September 5, 2025 22:51
@linux-foundation-easycla

linux-foundation-easycla Bot commented Sep 5, 2025

Copy link
Copy Markdown

CLA Signed

The committers listed above are authorized under a signed CLA.

@wadells wadells changed the title Reuse ssl context in the sync client Reuse ssl context in the python sync client Sep 5, 2025
@wadells wadells changed the title Reuse ssl context in the python sync client fix(python) Reuse ssl context in the python sync client Sep 5, 2025
@coderabbitai

coderabbitai Bot commented Sep 5, 2025

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

Reworks RESTClientObject TLS setup to build and reuse an SSLContext, loading CA and client certs, optionally disabling verification. Passes the SSLContext to urllib3 PoolManager/ProxyManager, removing explicit cert_reqs. Adds a comment referencing OpenSSL 3.0+ performance considerations. Public API remains unchanged.

Changes

Cohort / File(s) Summary
TLS configuration and urllib3 initialization
config/clients/python/template/src/sync/rest.py.mustache
Replace cert_reqs logic with a reusable ssl.SSLContext built via create_default_context; load client cert/key if provided; disable hostname checking and verification when verify_ssl is false; pass ssl_context to urllib3 ProxyManager/PoolManager; retain ca_certs and cert/key args; add comment on OpenSSL performance.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    actor User
    participant Config as Configuration
    participant REST as RESTClientObject.__init__
    participant SSL as ssl.SSLContext
    participant U3 as urllib3 (Pool/Proxy Manager)

    User->>REST: instantiate with Configuration
    REST->>Config: read ssl_ca_cert, cert_file, key_file, verify_ssl, proxies
    REST->>SSL: create_default_context(cafile)
    alt client cert provided
        REST->>SSL: load_cert_chain(cert_file, keyfile)
    end
    alt verify_ssl == false
        REST->>SSL: set check_hostname = False, verify_mode = CERT_NONE
    end
    alt proxy configured
        REST->>U3: ProxyManager(proxy_url, ssl_context=SSL, ca_certs, cert, key)
    else no proxy
        REST->>U3: PoolManager(ssl_context=SSL, ca_certs, cert, key)
    end
    REST-->>User: client ready (API unchanged)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
config/clients/python/template/src/sync/rest.py.mustache (3)

506-508: Don't clear the pool per request; release the connection instead.

Calling self.close() nukes the pool and defeats connection reuse, undermining this PR’s goal. Release the connection only when we’ve preloaded the content; leave raw responses to callers.

-        # Release the connection back to the pool
-        self.close()
+        # Release the connection back to the pool when preloaded
+        if _preload_content:
+            raw_response.release_conn()

257-268: Fix isinstance() unions — current code raises at runtime.

isinstance(timeout_val, float | int) is invalid; use a tuple. Same for tuple detection stays as-is.

-        if isinstance(timeout_val, float | int):
+        if isinstance(timeout_val, (float, int)):
             if timeout_val > 100:
                 timeout_val /= 1000
             timeout = urllib3.Timeout(total=timeout_val)
         elif isinstance(timeout_val, tuple) and len(timeout_val) == 2:

302-304: Same isinstance() fix for body type check.

-            elif isinstance(body, str | bytes):
+            elif isinstance(body, (str, bytes)):
                 args["body"] = body
🧹 Nitpick comments (2)
config/clients/python/template/src/sync/rest.py.mustache (2)

282-285: Encode repeated query params correctly.

urlencode(..., doseq=True) preserves repeated keys and list values.

-        if query_params:
-            encoded_qs = urllib.parse.urlencode(query_params)
+        if query_params:
+            encoded_qs = urllib.parse.urlencode(query_params, doseq=True)
             args["url"] = f"{url}?{encoded_qs}"

45-49: Docstring param name mismatch.

The docstring refers to :param resp: but the parameter is response.

-        :param resp: The urllib3.HTTPResponse object.
+        :param response: The urllib3.HTTPResponse object.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 6442485 and ba7bd3a.

📒 Files selected for processing (1)
  • config/clients/python/template/src/sync/rest.py.mustache (3 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
config/**/*.mustache

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Validate mustache syntax and variable references across all template files, including CHANGELOG.md.mustache

Files:

  • config/clients/python/template/src/sync/rest.py.mustache
config/**/*.{json,mustache}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Never hardcode API keys or credentials in configuration or template files

Files:

  • config/clients/python/template/src/sync/rest.py.mustache

Comment thread config/clients/python/template/src/sync/rest.py.mustache
Comment thread config/clients/python/template/src/sync/rest.py.mustache
Comment thread config/clients/python/template/src/sync/rest.py.mustache
When ssl_context is provided, urllib3 uses it and ignores per-arg TLS
settings. Passing both is redundant.
@rhamzeh

rhamzeh commented Sep 10, 2025

Copy link
Copy Markdown
Member

Thanks for the PR, @wadells!

While my Python competency is definitely less than that of @evansims, some thoughts from a higher level:

  • This addresses the immediate need, but maybe it'd be preferable to follow the pattern we already use in other SDKs (.NET, Go, and JS): allow callers to inject a fully configured HTTP client. That gives consumers control over SSL settings (and also proxies, timeouts, connection pooling, etc..), and keeps the SDK surface area small and consistent across languages.
  • Exposing ssl_context directly isn't a pattern I'm seeing in other popular Python SDKs, which leads me to think we should not be adding it here if we can avoid it.

To unblock you, short-term options if you need this now while we collaborate on the HTTP client injection:

  • We can maintain a branch with your changes that you can install from git, e.g.:
    pip install git+https://github.com/openfga/python-sdk.git@wip/ssl_context
  • We can publish a one-off unstable tag (e.g., v0.9.5+ssl_context or similar) you can target.

If you're open to it, we can collaborate to pivot this PR toward adding support for passing a custom HTTP client and use that to handle SSL context reuse cleanly.

What do you think?

@wadells

wadells commented Sep 10, 2025

Copy link
Copy Markdown
Contributor Author

@rhamzeh: Thanks for the reply!

Overall, I'd characterize my response as: "Don't let perfect get in the way of better."

Priority wise this is causing (admittedly relatively unnoticeable to humans) performance papercuts for hundreds of thousands of Zapier customers. Probably 10 hours of human time wasted per day, in 30-40ms slices.

  • This addresses the immediate need, but maybe it'd be preferable to follow the pattern we already use in other SDKs (.NET, Go, and JS): allow callers to inject a fully configured HTTP client. That gives consumers control over SSL settings (and also proxies, timeouts, connection pooling, etc..), and keeps the SDK surface area small and consistent across languages.

I agree -- this is a much cleaner architecture, and I would have loved to have this tuning knob when I came across this ssl perf issue.

However:

  • If we change it here, we should also update the async client for consistency
  • Consumer facing API edits would fall under a semver minor release. More likely, we wouldn't want to keep the old logic around, and this would be a breaking major update (0.x notwithstanding) that would force consumers to change how they instantiate clients. Creating a client is pretty fundamental -- there isn't a user of this library that wouldn't need to update call sites.
  • Testing and documentation for those changes. I feel comfortable cheating through without unit tests on this patch because the logic is what we already use in the async client, but I don't think we could get away with that for the proposed refactoring.

This is major scope creep. If this a route Okta would like to go, I propose that you all make these enhancements after the performance issue is no longer a concern.

I'd like to point out my patch to the sync client is identical to how ssl configuration is currently handled in the async client. This a design choice @adriantam and you made in #22. This isn't a new pattern for the codebase, this is fixing a performance diff between the two clients.

  • Exposing ssl_context directly isn't a pattern I'm seeing in other popular Python SDKs, which leads me to think we should not be adding it here if we can avoid it.

I'm puzzled by this remark. Could you perhaps rephrase?

I don't add or expose ssl_context it in the openfga-sdk API. This patch doesn't change any public signatures or configuration options.

To unblock you, short-term options if you need this now while we collaborate on the HTTP client injection:

  • We can publish a one-off unstable tag (e.g., v0.9.5+ssl_context or similar) you can target.

Alternatively, consider a release addressing performance issues as 0.9.6 (it is a bugfix after all) and we can change the public facing API as needed in v0.10.0.

If you're open to it, we can collaborate to pivot this PR toward adding support for passing a custom HTTP client and use that to handle SSL context reuse cleanly.

As discussed above, I'd rather have a separate patch for (potentially breaking) API changes, and keep this one focused on a pretty clear cut ssl instantiation performance difference between the async and the sync client.

@rhamzeh

rhamzeh commented Sep 10, 2025

Copy link
Copy Markdown
Member

@wadells - that makes sense. There's some minor things that we can tackle in follow-ups.

Would you mind signing the Linux Foundation's CLA here: #607 (comment) so we can merge? It's enforced by the CNCF that it be signed before we can get this merged.

@wadells

wadells commented Sep 10, 2025

Copy link
Copy Markdown
Contributor Author

@wadells - that makes sense. There's some minor things that we can tackle in follow-ups.

For anything that doesn't touch the public api or also require refactoring the async client, I'd be happy to address it in this PR. For those bigger changes, lets handle them as follow ups.

Would you mind signing the Linux Foundation's CLA here: #607 (comment) so we can merge? It's enforced by the CNCF that it be signed before we can get this merged.

Unfortunately, I'm not authorized sign this on behalf of Zapier. I reached out to my legal team (who does have the power to sign on behalf of the company) yesterday and they've been assessing. I'll poke them again now.

I could probably cheat around it and sign as an individual, but these changes were developed as part of my job so the ethical thing is to wait for Zapier legal.

@wadells

wadells commented Sep 11, 2025

Copy link
Copy Markdown
Contributor Author

@rhamzeh: CLA signed!

@rhamzeh

rhamzeh commented Sep 11, 2025

Copy link
Copy Markdown
Member

@wadells thanks! Will aim to get this merged an in the Python SDK by tomorrow. Thanks for the investigation and the fix ❤️

@dyeam0

dyeam0 commented Sep 12, 2025

Copy link
Copy Markdown
Member

Thanks @wadells!

@rhamzeh rhamzeh changed the title fix(python) Reuse ssl context in the python sync client fix(python): reuse ssl context in the python sync client Sep 12, 2025
rhamzeh pushed a commit to openfga/python-sdk that referenced this pull request Sep 12, 2025
This brings ssl context handling in line with the async client.
Importantly, openssl has a pretty signifigant performance regression
in creating ssl contexts v3.0+ that is mitigated by paying the context
creation tax once, instead of for every request.

Based on testing, this reduces the openssl v3 performance penalty
from ~200ms per connection to 9ms per connection.

Original PR: openfga/sdk-generator#607
github-merge-queue Bot pushed a commit to openfga/python-sdk that referenced this pull request Sep 12, 2025
* fix: reuse ssl context in the sync client

This brings ssl context handling in line with the async client.
Importantly, openssl has a pretty signifigant performance regression
in creating ssl contexts v3.0+ that is mitigated by paying the context
creation tax once, instead of for every request.

Based on testing, this reduces the openssl v3 performance penalty
from ~200ms per connection to 9ms per connection.

Original PR: openfga/sdk-generator#607

* chore: add tests for ssl context reuse

---------

Co-authored-by: Walt Della <walt.della@zapier.com>
@rhamzeh
rhamzeh added this pull request to the merge queue Sep 12, 2025
Merged via the queue into openfga:main with commit 2d4c438 Sep 12, 2025
15 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Sep 16, 2025
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants