Skip to content

connect()

Open and return a ready Connection (blocking).

Four transport paths
  • message server: mshost set → resolve the least-loaded app server via MessageServerClient.resolve(group), then direct-TCP connect (TRANS-03).
  • SAProuter: saprouter set → prepend the NI_ROUTE prefix before the direct-TCP handshake (TRANS-02).
  • wRFC (WebSocket RFC over TLS): wshost set → route through connect_ws (SEC-05, D-16/D-17). wsport defaults to 443 and ws_path to /sap/bc/rfc (D-19). Optional ws_proxy_* params tunnel the connection through an HTTP CONNECT forward proxy (D-20).
  • SNC (Secure Network Communications): snc_lib set (and wshost absent) → wrap the direct-TCP transport in an :class:~saprfclib.snc.SncTransport that drives the GSS-API handshake to COMPLETE before any data is sent (SEC-02/03/04/06, D-13). snc_lib presence is the activation switch — there is no separate mode flag. snc_sso defaults to False (D-12). wshost takes precedence: SNC-over-wRFC is out of scope for Phase 7.

snc_qop selects the protection level and defaults to 3. Accepted values are 1 (authentication only), 2 (integrity), 3 (privacy), 8 (the system default) and 9 (the maximum available); anything else raises ValueError rather than picking a level, because the value decides whether payloads are encrypted.

8 is an approximation. SAP defines it as "apply the system's default protection", which comes from the server's snc/data_protection/use profile parameter. This library does not read that parameter, so 8 is treated exactly as 3. That errs toward more protection rather than less, which is the only direction it is safe to approximate in — but it is an approximation, not a negotiated answer. Pass 1, 2 or 3 if you need to know precisely what is applied. - direct: port = 3300 + int(sysnr) (gateway port unless port is given), connect_tcp, handshake.

proxy_type="socks5" routes the raw-TCP connection through a SOCKS5 proxy — the interface the SAP BTP Connectivity Proxy exposes so Kyma/Kubernetes workloads reach on-premise systems via SAP Cloud Connector (issue #51, D-39). proxy_host and proxy_port are required (no default is assumed). SAP uses two SOCKS5 authentication methods and never RFC 1929 user/password (D-40):

  • no authentication (method 0x00) when none of the JWT parameters are set — the Connectivity Proxy in trusted mode (enableProxyAuthorization=false, the default for in-cluster workloads).
  • SAP JWT (method 0x80) when proxy_jwt is supplied, or when proxy_client_id/proxy_client_secret/proxy_token_url are all set — then a client_credentials token is fetched (D-41). An optional proxy_scc_location_id selects a specific Cloud Connector location.

socket_factory is the generic form of the same seam: a Callable[[host, port], socket] that returns an already-connected socket. It is mutually exclusive with proxy_type. Neither applies to the wRFC (wshost) path, which proxies via ws_proxy_*.

gwhost is the gateway host written into the GW handshake frames, which the server-side gateway resolves. It defaults to ashost and should be left unset for a direct connection. Set it for a proxy / Cloud Connector path: ashost is then the virtual host used only to open the TCP tunnel (resolved by the proxy), while gwhost must be the SAP system's own internal hostname — the name the gateway resolves to itself. A virtual gwhost makes the gateway reject the connection with hostname '…' unknown (issue #51).

lang is the logon language. Accepts the one-character SAP code ('E' English, 'D' German, 'S' Spanish, …) or the two-character ISO code ('EN', 'DE', 'ES'); an ISO code is converted before the logon frame is built, matching the SDK's LANG option.

user and passwd may both be omitted. That is read as a deliberate anonymous attempt and the logon frame goes out without the user and password records — some systems answer a small set of function modules that way, while a hardened one refuses below the RFC layer and raises CommunicationError. Supplying exactly one of the two raises ValueError, since that is a missing setting rather than a request to connect anonymously. SNC connections are unaffected: snc_lib carries its own credentials.

strict_params controls what call() does with a keyword argument the function interface does not declare. The default (False) drops it and logs a warning, which is what callers porting from pyrfc expect when they pass a superset of kwargs across differing SAP releases. Set True to raise ValueError instead — worth doing when a dropped argument would change the result, since the server has no way to tell you an argument never arrived.

allow_restricted_logon (default False) governs a logon that AUTHENTICATED but whose embedded RFCPING the server rejected — typically S_RFC (RFC_NO_AUTHORITY) for a low-privilege user (e.g. TMSADM). By default that raises :class:~saprfclib.exceptions.AbapApplicationError with the server's exception, matching the reference client. Set True to return a usable connection instead: authentication and function authorization are separate, and the session is ready for the caller's own authorized calls. Intended for tools that enumerate restricted RFC destinations (issue #38).

trace attaches an :class:~saprfclib.trace.RfcTrace, which writes an SDK-format trace file of every frame. It is a parameter rather than an environment variable on purpose: the SDK reads RFC_TRACE from the environment, but a process that starts writing traffic to disk because of a variable it inherited is a surprise, and the file — though credential-redacted — still contains everything else that crossed the wire. Turning it on should be visible at the call site.

The SAProuter and message-server wire formats were live-verified after this docstring first called them unverified: the NI_ROUTE payload is byte-exact against a capture (tests/golden/router/ni_route_payload.bin), a router that accepts a route answers NI_PONG and one that refuses answers NI_RTERR, and the message server answers the binary attach and server-list frames as MSG_SERVER. What remains unconfirmed is narrower and sits in router.py: some field boundaries inside a server-list entry, and whether the entry count is carried in the header or only implied by the payload length. passwd, ws_proxy_pass, proxy_jwt, proxy_client_secret, snc_lib, snc_partnername and snc_myname are never logged or echoed into any log message or exception string (threats T-03-CRED2 / T-07-CRED / T-07-PROXY-CRED).

Source code in src/saprfclib/connection.py
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
4161
4162
4163
4164
4165
4166
4167
4168
4169
4170
4171
4172
4173
4174
4175
4176
4177
4178
4179
4180
4181
4182
4183
4184
4185
4186
4187
4188
4189
4190
4191
4192
4193
4194
4195
4196
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
4315
4316
4317
4318
4319
4320
4321
4322
4323
4324
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
4343
4344
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
4356
4357
4358
4359
4360
4361
4362
4363
4364
4365
4366
4367
4368
4369
4370
4371
4372
4373
4374
4375
4376
4377
4378
4379
4380
4381
4382
4383
4384
4385
4386
4387
4388
4389
4390
4391
4392
4393
4394
4395
4396
4397
4398
4399
4400
4401
4402
4403
4404
4405
4406
4407
4408
4409
4410
4411
4412
4413
4414
4415
4416
4417
4418
4419
4420
4421
4422
4423
4424
4425
4426
4427
4428
4429
4430
4431
4432
4433
4434
4435
4436
4437
4438
4439
4440
4441
4442
4443
4444
4445
4446
4447
4448
4449
4450
4451
4452
4453
4454
4455
4456
4457
4458
4459
4460
4461
4462
4463
4464
4465
4466
4467
4468
4469
4470
4471
4472
4473
def connect(
    ashost: str,
    sysnr: str | int,
    client: str,
    user: str | None = None,
    passwd: str | None = None,
    *,
    lang: str = _DEFAULT_LANG,
    strict_params: bool = False,
    allow_restricted_logon: bool = False,
    timeout: float | None = None,
    connect_timeout: float | None = DEFAULT_CONNECT_TIMEOUT,
    read_timeout: float | None = DEFAULT_READ_TIMEOUT,
    metadata_cache: MetadataCache | None = None,
    metadata_cache_key: str | None = None,
    saprouter: str | None = None,
    mshost: str | None = None,
    msserv: int | str | None = None,
    ms_http_port: int | str | None = None,
    ms_use_http: bool = True,
    sysid: str | None = None,
    group: str | None = None,
    port: int | None = None,
    wshost: str | None = None,
    wsport: int | None = None,
    ws_path: str | None = None,
    ws_proxy_host: str | None = None,
    ws_proxy_port: int | None = None,
    ws_proxy_user: str | None = None,
    ws_proxy_pass: str | None = None,
    ws_tls_verify: bool = True,
    proxy_type: str | None = None,
    proxy_host: str | None = None,
    proxy_port: int | None = None,
    proxy_jwt: str | None = None,
    proxy_scc_location_id: str | None = None,
    proxy_client_id: str | None = None,
    proxy_client_secret: str | None = None,
    proxy_token_url: str | None = None,
    socket_factory: Callable[[str, int], socket.socket] | None = None,
    gwhost: str | None = None,
    trace: RfcTrace | None = None,
    snc_lib: str | None = None,
    snc_partnername: str | None = None,
    snc_myname: str | None = None,
    snc_qop: int | None = None,
    snc_sso: bool | None = None,
    max_retries: int = 3,
    retry_delay: float = 1.0,
    tid_store: TidStore | None = None,
    unit_store: UnitStore | None = None,
) -> Connection:
    """Open and return a ready Connection (blocking).

    Four transport paths:
      - message server: ``mshost`` set → resolve the least-loaded app server via
        MessageServerClient.resolve(group), then direct-TCP connect (TRANS-03).
      - SAProuter: ``saprouter`` set → prepend the NI_ROUTE prefix before the
        direct-TCP handshake (TRANS-02).
      - wRFC (WebSocket RFC over TLS): ``wshost`` set → route through
        ``connect_ws`` (SEC-05, D-16/D-17). ``wsport`` defaults to 443 and
        ``ws_path`` to ``/sap/bc/rfc`` (D-19). Optional ``ws_proxy_*`` params
        tunnel the connection through an HTTP CONNECT forward proxy (D-20).
      - SNC (Secure Network Communications): ``snc_lib`` set (and ``wshost``
        absent) → wrap the direct-TCP transport in an :class:`~saprfclib.snc.SncTransport`
        that drives the GSS-API handshake to COMPLETE before any data is sent
        (SEC-02/03/04/06, D-13). ``snc_lib`` presence is the activation switch —
        there is no separate mode flag. ``snc_sso`` defaults to False (D-12).
        ``wshost`` takes precedence: SNC-over-wRFC is out of scope for Phase 7.

        ``snc_qop`` selects the protection level and defaults to 3. Accepted
        values are 1 (authentication only), 2 (integrity), 3 (privacy),
        8 (the system default) and 9 (the maximum available); anything else
        raises ``ValueError`` rather than picking a level, because the value
        decides whether payloads are encrypted.

        **8 is an approximation.** SAP defines it as "apply the system's default
        protection", which comes from the server's ``snc/data_protection/use``
        profile parameter. This library does not read that parameter, so 8 is
        treated exactly as 3. That errs toward more protection rather than less,
        which is the only direction it is safe to approximate in — but it is an
        approximation, not a negotiated answer. Pass 1, 2 or 3 if you need to know
        precisely what is applied.
      - direct: ``port = 3300 + int(sysnr)`` (gateway port unless ``port`` is
        given), connect_tcp, handshake.

    ``proxy_type="socks5"`` routes the raw-TCP connection through a SOCKS5 proxy —
    the interface the SAP BTP Connectivity Proxy exposes so Kyma/Kubernetes
    workloads reach on-premise systems via SAP Cloud Connector (issue #51, D-39).
    ``proxy_host`` and ``proxy_port`` are required (no default is assumed). SAP
    uses two SOCKS5 authentication methods and never RFC 1929 user/password (D-40):

      - **no authentication** (method 0x00) when none of the JWT parameters are
        set — the Connectivity Proxy in trusted mode
        (``enableProxyAuthorization=false``, the default for in-cluster workloads).
      - **SAP JWT** (method 0x80) when ``proxy_jwt`` is supplied, or when
        ``proxy_client_id``/``proxy_client_secret``/``proxy_token_url`` are all set
        — then a ``client_credentials`` token is fetched (D-41). An optional
        ``proxy_scc_location_id`` selects a specific Cloud Connector location.

    ``socket_factory`` is the generic form of the same seam: a
    ``Callable[[host, port], socket]`` that returns an already-connected socket. It
    is mutually exclusive with ``proxy_type``. Neither applies to the wRFC
    (``wshost``) path, which proxies via ``ws_proxy_*``.

    ``gwhost`` is the gateway host written into the GW handshake frames, which the
    **server-side gateway resolves**. It defaults to ``ashost`` and should be left
    unset for a direct connection. Set it for a proxy / Cloud Connector path:
    ``ashost`` is then the *virtual* host used only to open the TCP tunnel (resolved
    by the proxy), while ``gwhost`` must be the SAP system's own internal hostname —
    the name the gateway resolves to itself. A virtual ``gwhost`` makes the gateway
    reject the connection with ``hostname '…' unknown`` (issue #51).

    ``lang`` is the logon language. Accepts the one-character SAP code ('E' English,
    'D' German, 'S' Spanish, …) or the two-character ISO code ('EN', 'DE', 'ES'); an
    ISO code is converted before the logon frame is built, matching the SDK's LANG
    option.

    ``user`` and ``passwd`` may both be omitted. That is read as a deliberate
    anonymous attempt and the logon frame goes out without the user and password
    records — some systems answer a small set of function modules that way, while a
    hardened one refuses below the RFC layer and raises ``CommunicationError``.
    Supplying exactly one of the two raises ``ValueError``, since that is a missing
    setting rather than a request to connect anonymously. SNC connections are
    unaffected: ``snc_lib`` carries its own credentials.

    ``strict_params`` controls what ``call()`` does with a keyword argument the
    function interface does not declare. The default (False) drops it and logs a
    warning, which is what callers porting from pyrfc expect when they pass a
    superset of kwargs across differing SAP releases. Set True to raise ValueError
    instead — worth doing when a dropped argument would change the result, since the
    server has no way to tell you an argument never arrived.

    ``allow_restricted_logon`` (default False) governs a logon that AUTHENTICATED
    but whose embedded RFCPING the server rejected — typically S_RFC
    (``RFC_NO_AUTHORITY``) for a low-privilege user (e.g. ``TMSADM``). By default
    that raises :class:`~saprfclib.exceptions.AbapApplicationError` with the
    server's exception, matching the reference client. Set True to return a usable
    connection instead: authentication and function authorization are separate, and
    the session is ready for the caller's own authorized calls. Intended for tools
    that enumerate restricted RFC destinations (issue #38).

    ``trace`` attaches an :class:`~saprfclib.trace.RfcTrace`, which writes an
    SDK-format trace file of every frame. It is a parameter rather than an
    environment variable on purpose: the SDK reads ``RFC_TRACE`` from the
    environment, but a process that starts writing traffic to disk because of a
    variable it inherited is a surprise, and the file — though credential-redacted
    — still contains everything else that crossed the wire. Turning it on should
    be visible at the call site.

    The SAProuter and message-server wire formats were live-verified after this
    docstring first called them unverified: the NI_ROUTE payload is byte-exact
    against a capture (``tests/golden/router/ni_route_payload.bin``), a router
    that accepts a route answers ``NI_PONG`` and one that refuses answers
    ``NI_RTERR``, and the message server answers the binary attach and
    server-list frames as ``MSG_SERVER``. What remains unconfirmed is narrower and
    sits in ``router.py``: some field boundaries inside a server-list entry, and
    whether the entry count is carried in the header or only implied by the
    payload length. ``passwd``,
    ``ws_proxy_pass``, ``proxy_jwt``, ``proxy_client_secret``, ``snc_lib``,
    ``snc_partnername`` and ``snc_myname`` are never logged or echoed into any log
    message or exception string (threats T-03-CRED2 / T-07-CRED / T-07-PROXY-CRED).
    """
    # Imported lazily so the direct-TCP facade carries no hard dependency on the
    # alternate-transport layer (router.py, plan 03-03 Task 2).
    from saprfclib.router import (
        open_route,
        open_route_async,
        parse_route_string,
    )

    user, passwd = _resolve_credentials(user, passwd, snc_lib=snc_lib, ashost=ashost)

    # SOCKS5 proxy (issue #51, D-39/40/41). Build a socket_factory once; it reaches
    # the gateway through the proxy and everything above the socket is unchanged.
    _validate_proxy_args(
        proxy_type, proxy_host, proxy_port, socket_factory, wshost=wshost, gwhost=gwhost
    )
    if proxy_type == "socks5":
        _proxy_jwt = _resolve_proxy_jwt(
            proxy_jwt,
            proxy_client_id,
            proxy_client_secret,
            proxy_token_url,
            connect_timeout=connect_timeout,
        )
        socket_factory = _make_socks5_socket_factory(
            proxy_host,  # type: ignore[arg-type]  # validated non-None above
            proxy_port,  # type: ignore[arg-type]
            jwt=_proxy_jwt,
            scc_location_id=proxy_scc_location_id or "",
            connect_timeout=connect_timeout,
            read_timeout=read_timeout,
        )

    if mshost is not None:
        # Message-server group logon: resolve to a concrete (ashost, sysnr).
        ashost, sysnr = _resolve_via_message_server(
            mshost,
            group=group,
            sysid=sysid,
            msserv=msserv,
            ms_http_port=ms_http_port,
            use_http=ms_use_http,
            timeout=timeout,
            connect_timeout=connect_timeout,
            read_timeout=read_timeout,
        )

    # Gateway port. Confirmed by SAP's "TCP/IP Ports of All SAP Products":
    #   Gateway          sapgw<NN>    3300   range 3300-3399   33<NN>
    #   Gateway secured  sapgw<NN>s   4800   range 4800-4899   48<NN>
    # <NN> is the application server's own instance number here, unlike the
    # message server. Also confirmed live: the A4H message server reports
    # RFC=3300 and RFCS=4800 for a sysnr-00 application server.
    sysnr = _validate_sysnr(sysnr)
    # An explicit port replaces the derivation entirely. The convention holds for a
    # gateway reachable at its own address; it does not survive NAT, a port-forward
    # or a jump host, and there the caller knows the port and the library cannot
    # derive it. When None nothing changes, so the SNC branch still defaults to 4800.
    port = port if port is not None else (4800 if snc_lib is not None else 3300) + sysnr

    # ------------------------------------------------------------------ #
    # Transport routing (Phase 7): wRFC first, then SNC, then plain TCP.  #
    # Both branches are additive — when ``wshost`` and ``snc_lib`` are    #
    # None the plain connect_tcp path below is byte-for-byte unchanged    #
    # (SEC-01 / T-07-REGRESSION). ``wshost`` wins over ``snc_lib`` because #
    # SNC-over-wRFC is out of scope for Phase 7 (D-13).                    #
    # ------------------------------------------------------------------ #
    if wshost is not None:
        # wRFC over TLS (SEC-05, D-16/D-17). Lazy import mirrors the
        # router lazy import above so a bare ``import saprfclib`` never hard-
        # depends on the WebSocket stack at import time.
        from saprfclib.ws import connect_ws

        transport = connect_ws(
            wshost,
            wsport or 443,
            ws_path=ws_path or "/sap/bc/rfc?sap-apc-stateful=true",
            ws_proxy_host=ws_proxy_host,
            ws_proxy_port=ws_proxy_port,
            ws_proxy_user=ws_proxy_user,
            ws_proxy_pass=ws_proxy_pass,
            user=user,
            passwd=passwd,
            sap_client=client,
            verify=ws_tls_verify,
            timeout=timeout,
            read_timeout=read_timeout,
        )
        conn = Connection(
            transport,  # type: ignore[arg-type]
            strict_params=strict_params,
            allow_restricted_logon=allow_restricted_logon,
            metadata_cache=metadata_cache,
            metadata_cache_key=metadata_cache_key,
        )
    elif snc_lib is not None:
        # SNC (SEC-02/03/04/06, D-13): SAP protocol order requires the NI
        # version exchange to complete on the plain channel BEFORE the GSS
        # frames are sent. SncTransport then drives FR_INIT/FR_ACCEPT to
        # COMPLETE; GW connect and logon flow through the encrypted channel.
        #
        # T-07-CRED: snc_lib / snc_partnername / snc_myname are passed
        # straight through — never placed into a log or an exception string.
        from saprfclib.snc import SncTransport, validate_snc_qop

        _inner = connect_tcp(
            ashost,
            port,
            timeout=timeout,
            connect_timeout=connect_timeout,
            read_timeout=read_timeout,
            trace=trace,
            socket_factory=socket_factory,
        )

        # Step 1: NI version exchange on the plain inner transport.
        _snc_sess = Session(allow_restricted_logon=allow_restricted_logon)
        try:
            _snc_lip = _inner._sock.getsockname()[0]
        except Exception:
            _snc_lip = "127.0.0.1"
        _inner.send_message(_snc_sess.start(local_ip=_snc_lip))
        _snc_sess.feed(_inner.recv_message())
        # _snc_sess is now NI_VERSIONED.

        # Step 2: GSS handshake on the versioned channel.
        transport = SncTransport(  # type: ignore[assignment]
            _inner,
            snc_lib=snc_lib,
            snc_partnername=snc_partnername,  # type: ignore[arg-type]
            snc_myname=snc_myname,
            snc_qop=validate_snc_qop(snc_qop or 3),  # D-12: privacy is the default QOP
            snc_sso=snc_sso or False,  # D-12: SSO2 off by default (D-23 gap)
        )

        # Step 3: Connection with the pre-versioned session so _handshake()
        # resumes from NI_VERSIONED (skips the NI leg, starts at GW connect).
        conn = Connection(
            transport,  # type: ignore[arg-type]
            strict_params=strict_params,
            allow_restricted_logon=allow_restricted_logon,
            metadata_cache=metadata_cache,
            metadata_cache_key=metadata_cache_key,
        )
        conn._session = _snc_sess
        conn._snc_mode = True
    else:
        # ------------------------------------------------------------------ #
        # Classic async-core path (D-06/D-07): direct TCP / SAProuter /       #
        # message-server connections all use AsyncConnection + _LoopThread.   #
        # SAProuter NI_ROUTE is prepended inside the async setup coroutine.   #
        # Returns early — the shared saprouter/handshake lines below are for  #
        # SNC / wRFC paths only (scope boundary — Phase 9).                   #
        # ------------------------------------------------------------------ #
        loop_thread = _LoopThread()

        # Capture locals for the async closure (avoid late-binding issues).
        _ashost = ashost
        _port = port
        _timeout = timeout
        _connect_timeout = connect_timeout
        _read_timeout = read_timeout
        _metadata_cache = metadata_cache
        _metadata_cache_key = metadata_cache_key
        _saprouter = saprouter
        _client = client
        _user = user
        _passwd = passwd
        _lang = lang
        _strict = strict_params
        _allow_restricted = allow_restricted_logon
        _sysnr = int(sysnr)
        _max_retries = max_retries
        _retry_delay = retry_delay
        _tid_store = tid_store
        _unit_store = unit_store
        _socket_factory = socket_factory
        _gwhost = gwhost

        async def _async_setup() -> AsyncConnection:
            # Use connect_tcp (sync, patchable in tests) wrapped in a thin async shim.
            # connect_async() uses real asyncio open_connection for non-blocking I/O.
            # This keeps the existing test suite (which patches connect_tcp) green (D-07).
            sync_t = connect_tcp(
                _ashost,
                _port,
                timeout=_timeout,
                connect_timeout=_connect_timeout,
                read_timeout=_read_timeout,
                trace=trace,
                socket_factory=_socket_factory,
            )
            at: _SyncToAsyncTransport = _SyncToAsyncTransport(sync_t)
            if _saprouter is not None:
                hops = parse_route_string(_saprouter)
                await open_route_async(at, hops, _ashost, str(_port))
            ac = AsyncConnection(
                at,  # type: ignore[arg-type]
                max_retries=_max_retries,
                retry_delay=_retry_delay,
                tid_store=_tid_store,
                unit_store=_unit_store,
                strict_params=_strict,
                allow_restricted_logon=_allow_restricted,
                metadata_cache=_metadata_cache,
                metadata_cache_key=_metadata_cache_key,
            )
            await ac._handshake(
                client=_client,
                user=_user,
                passwd=_passwd,
                ashost=_ashost,
                sysnr=_sysnr,
                lang=_lang,
                gwhost=_gwhost,
            )
            return ac

        try:
            async_conn = loop_thread.run(_async_setup())
        except Exception:
            loop_thread.close()
            raise
        return Connection._from_async(async_conn, loop_thread)

    if saprouter is not None:
        # Prepend the NI_ROUTE control frame before the handshake (TRANS-02).
        # Wire format confirmed from live capture 2026-06-27.
        # NOTE: only reached by SNC/wRFC branches (classic path returns above).
        hops = parse_route_string(saprouter)
        open_route(transport, hops, ashost, str(port))

    conn._handshake(
        client=client,
        user=user,
        passwd=passwd,
        ashost=ashost,
        sysnr=int(sysnr),
        lang=lang,
        gwhost=gwhost,
    )
    return conn