Skip to content

Commit 6095728

Browse files
authored
fix: auto-detect etcd API prefix for v3.4.x compatibility (#382)
* fix: auto-detect etcd API prefix for v3.4.x compatibility * fix: improve etcd API prefix detection for all versions * fix: address PR review — smarter probe status handling, no double error wrapping
1 parent 6846b61 commit 6095728

File tree

2 files changed

+115
-25
lines changed

2 files changed

+115
-25
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- etcd connection failing with 404 when gRPC gateway uses a different API prefix (auto-detects `/v3/`, `/v3beta/`, `/v3alpha/`)
13+
1014
## [0.21.0] - 2026-03-19
1115

1216
### Added

Plugins/EtcdDriverPlugin/EtcdHttpClient.swift

Lines changed: 111 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)