@@ -313,6 +313,7 @@ internal final class EtcdHttpClient: @unchecked Sendable {
313313 private var currentTask : URLSessionDataTask ?
314314 private var authToken : String ?
315315 private var _isAuthenticating = false
316+ private var apiPrefix = " v3 "
316317
317318 private static let logger = Logger ( subsystem: " com.TablePro " , category: " EtcdHttpClient " )
318319
@@ -332,6 +333,13 @@ internal final class EtcdHttpClient: @unchecked Sendable {
332333 return " \( scheme) :// \( config. host) : \( config. port) "
333334 }
334335
336+ private func apiPath( _ suffix: String ) -> String {
337+ lock. lock ( )
338+ let prefix = apiPrefix
339+ lock. unlock ( )
340+ return " \( prefix) / \( suffix) "
341+ }
342+
335343 // MARK: - Connection Lifecycle
336344
337345 func connect( ) async throws {
@@ -366,7 +374,14 @@ internal final class EtcdHttpClient: @unchecked Sendable {
366374 lock. unlock ( )
367375
368376 do {
369- try await ping ( )
377+ try await detectApiPrefix ( )
378+ } catch let etcdError as EtcdError {
379+ lock. lock ( )
380+ session? . invalidateAndCancel ( )
381+ session = nil
382+ lock. unlock ( )
383+ Self . logger. error ( " Connection test failed: \( etcdError. localizedDescription) " )
384+ throw etcdError
370385 } catch {
371386 lock. lock ( )
372387 session? . invalidateAndCancel ( )
@@ -399,56 +414,125 @@ internal final class EtcdHttpClient: @unchecked Sendable {
399414 session = nil
400415 authToken = nil
401416 _isAuthenticating = false
417+ apiPrefix = " v3 "
402418 lock. unlock ( )
403419 }
404420
405421 func ping( ) async throws {
406- let _: EtcdStatusResponse = try await post ( path: " v3/maintenance/status " , body: EmptyBody ( ) )
422+ let _: EtcdStatusResponse = try await post ( path: apiPath ( " maintenance/status " ) , body: EmptyBody ( ) )
423+ }
424+
425+ /// Probes etcd gateway prefixes in order and selects the first that responds
426+ /// with a non-404 status. Covers all etcd versions:
427+ /// 3.5+ → /v3/ only
428+ /// 3.4 → /v3/ + /v3beta/
429+ /// 3.3 → /v3beta/ + /v3alpha/
430+ /// 3.2- → /v3alpha/ only
431+ private func detectApiPrefix( ) async throws {
432+ let candidates = [ " v3 " , " v3beta " , " v3alpha " ]
433+
434+ lock. lock ( )
435+ guard let session else {
436+ lock. unlock ( )
437+ throw EtcdError . notConnected
438+ }
439+ lock. unlock ( )
440+
441+ for candidate in candidates {
442+ guard let url = URL ( string: " \( baseUrl) / \( candidate) /maintenance/status " ) else {
443+ continue
444+ }
445+
446+ var request = URLRequest ( url: url)
447+ request. httpMethod = " POST "
448+ request. setValue ( " application/json " , forHTTPHeaderField: " Content-Type " )
449+ request. httpBody = try JSONEncoder ( ) . encode ( EmptyBody ( ) )
450+
451+ let response : URLResponse
452+ do {
453+ ( _, response) = try await session. data ( for: request)
454+ } catch {
455+ // Network-level failure — server is unreachable regardless of prefix
456+ throw error
457+ }
458+
459+ guard let httpResponse = response as? HTTPURLResponse else {
460+ throw EtcdError . serverError ( " Invalid response type " )
461+ }
462+
463+ switch httpResponse. statusCode {
464+ case 404 :
465+ continue
466+ case 200 :
467+ lock. lock ( )
468+ apiPrefix = candidate
469+ lock. unlock ( )
470+ Self . logger. debug ( " Detected etcd API prefix: \( candidate) " )
471+ return
472+ case 401 where !config. username. isEmpty:
473+ // Auth required but credentials are configured — prefix is valid,
474+ // authenticate() will run after detection
475+ lock. lock ( )
476+ apiPrefix = candidate
477+ lock. unlock ( )
478+ Self . logger. debug ( " Detected etcd API prefix: \( candidate) (auth required) " )
479+ return
480+ case 401 :
481+ throw EtcdError . authFailed ( " Authentication required " )
482+ default :
483+ Self . logger. warning ( " Prefix probe \( candidate) returned HTTP \( httpResponse. statusCode) " )
484+ throw EtcdError . serverError ( " Unexpected HTTP \( httpResponse. statusCode) from \( candidate) /maintenance/status " )
485+ }
486+ }
487+
488+ throw EtcdError . serverError (
489+ " No supported etcd API found (tried: \( candidates. joined ( separator: " , " ) ) ) "
490+ )
407491 }
408492
409493 // MARK: - KV Operations
410494
411495 func rangeRequest( _ req: EtcdRangeRequest ) async throws -> EtcdRangeResponse {
412- try await post ( path: " v3/ kv/range" , body: req)
496+ try await post ( path: apiPath ( " kv/range " ) , body: req)
413497 }
414498
415499 func putRequest( _ req: EtcdPutRequest ) async throws -> EtcdPutResponse {
416- try await post ( path: " v3/ kv/put" , body: req)
500+ try await post ( path: apiPath ( " kv/put " ) , body: req)
417501 }
418502
419503 func deleteRequest( _ req: EtcdDeleteRequest ) async throws -> EtcdDeleteResponse {
420- try await post ( path: " v3/ kv/deleterange" , body: req)
504+ try await post ( path: apiPath ( " kv/deleterange " ) , body: req)
421505 }
422506
423507 // MARK: - Lease Operations
424508
425509 func leaseGrant( ttl: Int64 ) async throws -> EtcdLeaseGrantResponse {
426510 let req = EtcdLeaseGrantRequest ( TTL: String ( ttl) )
427- return try await post ( path: " v3/ lease/grant" , body: req)
511+ return try await post ( path: apiPath ( " lease/grant " ) , body: req)
428512 }
429513
430514 func leaseRevoke( leaseId: Int64 ) async throws {
431515 let req = EtcdLeaseRevokeRequest ( ID: String ( leaseId) )
432- try await postVoid ( path: " v3/ lease/revoke" , body: req)
516+ try await postVoid ( path: apiPath ( " lease/revoke " ) , body: req)
433517 }
434518
435519 func leaseTimeToLive( leaseId: Int64 , keys: Bool ) async throws -> EtcdLeaseTimeToLiveResponse {
436520 let req = EtcdLeaseTimeToLiveRequest ( ID: String ( leaseId) , keys: keys)
437- return try await post ( path: " v3/ lease/timetolive" , body: req)
521+ return try await post ( path: apiPath ( " lease/timetolive " ) , body: req)
438522 }
439523
440524 func leaseList( ) async throws -> EtcdLeaseListResponse {
441- try await post ( path: " v3/ lease/leases" , body: EmptyBody ( ) )
525+ try await post ( path: apiPath ( " lease/leases " ) , body: EmptyBody ( ) )
442526 }
443527
444528 // MARK: - Cluster Operations
445529
446530 func memberList( ) async throws -> EtcdMemberListResponse {
447- try await post ( path: " v3/ cluster/member/list" , body: EmptyBody ( ) )
531+ try await post ( path: apiPath ( " cluster/member/list " ) , body: EmptyBody ( ) )
448532 }
449533
450534 func endpointStatus( ) async throws -> EtcdStatusResponse {
451- try await post ( path: " v3/ maintenance/status" , body: EmptyBody ( ) )
535+ try await post ( path: apiPath ( " maintenance/status " ) , body: EmptyBody ( ) )
452536 }
453537
454538 // MARK: - Watch
@@ -469,8 +553,9 @@ internal final class EtcdHttpClient: @unchecked Sendable {
469553 }
470554 let watchReq = EtcdWatchRequest ( createRequest: createReq)
471555
472- guard let url = URL ( string: " \( baseUrl) /v3/watch " ) else {
473- throw EtcdError . serverError ( " Invalid URL: \( baseUrl) /v3/watch " )
556+ let watchPath = apiPath ( " watch " )
557+ guard let url = URL ( string: " \( baseUrl) / \( watchPath) " ) else {
558+ throw EtcdError . serverError ( " Invalid URL: \( baseUrl) / \( watchPath) " )
474559 }
475560
476561 var request = URLRequest ( url: url)
@@ -525,58 +610,58 @@ internal final class EtcdHttpClient: @unchecked Sendable {
525610 // MARK: - Auth Management
526611
527612 func authEnable( ) async throws {
528- try await postVoid ( path: " v3/ auth/enable" , body: EmptyBody ( ) )
613+ try await postVoid ( path: apiPath ( " auth/enable " ) , body: EmptyBody ( ) )
529614 }
530615
531616 func authDisable( ) async throws {
532- try await postVoid ( path: " v3/ auth/disable" , body: EmptyBody ( ) )
617+ try await postVoid ( path: apiPath ( " auth/disable " ) , body: EmptyBody ( ) )
533618 }
534619
535620 func userAdd( name: String , password: String ) async throws {
536621 let req = EtcdUserAddRequest ( name: name, password: password)
537- try await postVoid ( path: " v3/ auth/user/add" , body: req)
622+ try await postVoid ( path: apiPath ( " auth/user/add " ) , body: req)
538623 }
539624
540625 func userDelete( name: String ) async throws {
541626 let req = EtcdUserDeleteRequest ( name: name)
542- try await postVoid ( path: " v3/ auth/user/delete" , body: req)
627+ try await postVoid ( path: apiPath ( " auth/user/delete " ) , body: req)
543628 }
544629
545630 func userList( ) async throws -> [ String ] {
546- let resp : EtcdUserListResponse = try await post ( path: " v3/ auth/user/list" , body: EmptyBody ( ) )
631+ let resp : EtcdUserListResponse = try await post ( path: apiPath ( " auth/user/list " ) , body: EmptyBody ( ) )
547632 return resp. users ?? [ ]
548633 }
549634
550635 func roleAdd( name: String ) async throws {
551636 let req = EtcdRoleAddRequest ( name: name)
552- try await postVoid ( path: " v3/ auth/role/add" , body: req)
637+ try await postVoid ( path: apiPath ( " auth/role/add " ) , body: req)
553638 }
554639
555640 func roleDelete( name: String ) async throws {
556641 let req = EtcdRoleDeleteRequest ( name: name)
557- try await postVoid ( path: " v3/ auth/role/delete" , body: req)
642+ try await postVoid ( path: apiPath ( " auth/role/delete " ) , body: req)
558643 }
559644
560645 func roleList( ) async throws -> [ String ] {
561- let resp : EtcdRoleListResponse = try await post ( path: " v3/ auth/role/list" , body: EmptyBody ( ) )
646+ let resp : EtcdRoleListResponse = try await post ( path: apiPath ( " auth/role/list " ) , body: EmptyBody ( ) )
562647 return resp. roles ?? [ ]
563648 }
564649
565650 func userGrantRole( user: String , role: String ) async throws {
566651 let req = EtcdUserGrantRoleRequest ( user: user, role: role)
567- try await postVoid ( path: " v3/ auth/user/grant" , body: req)
652+ try await postVoid ( path: apiPath ( " auth/user/grant " ) , body: req)
568653 }
569654
570655 func userRevokeRole( user: String , role: String ) async throws {
571656 let req = EtcdUserRevokeRoleRequest ( user: user, role: role)
572- try await postVoid ( path: " v3/ auth/user/revoke" , body: req)
657+ try await postVoid ( path: apiPath ( " auth/user/revoke " ) , body: req)
573658 }
574659
575660 // MARK: - Maintenance
576661
577662 func compaction( revision: Int64 , physical: Bool ) async throws {
578663 let req = EtcdCompactionRequest ( revision: String ( revision) , physical: physical)
579- try await postVoid ( path: " v3/ kv/compaction" , body: req)
664+ try await postVoid ( path: apiPath ( " kv/compaction " ) , body: req)
580665 }
581666
582667 // MARK: - Cancellation
@@ -710,7 +795,8 @@ internal final class EtcdHttpClient: @unchecked Sendable {
710795 }
711796
712797 let authReq = EtcdAuthRequest ( name: config. username, password: config. password)
713- guard let url = URL ( string: " \( baseUrl) /v3/auth/authenticate " ) else {
798+ let authPath = apiPath ( " auth/authenticate " )
799+ guard let url = URL ( string: " \( baseUrl) / \( authPath) " ) else {
714800 throw EtcdError . serverError ( " Invalid auth URL " )
715801 }
716802
0 commit comments