mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* Fix HTTP request deduplication bug caused by non-deterministic dictionary ordering ## Problem HTTP requests were not being properly deduplicated when they should have been. In production, we observed two POST receipt requests with identical content except for the $attConsentStatus timestamp (88ms apart), which should have been deduplicated but weren't. The root cause was that `individualizedCacheKeyPart` used `debugDescription` on the dictionary, which doesn't guarantee consistent ordering. This caused identical requests to generate different cache keys, preventing deduplication. ## Solution Changed the cache key computation to sort dictionary keys alphabetically before building the cache key string. This ensures deterministic ordering regardless of dictionary insertion order. ## Tests Added 1. Unit test: `testDictionaryIndividualizedCacheKeyPartIsDeterministicRegardlessOfInsertionOrder` - Verifies cache keys are identical despite different insertion orders - Verifies alphabetical sorting of keys in the output - FAILS without the fix, PASSES with it 2. Integration test: `testPostReceiptCachesRequestsWithMultipleAttributesAndDifferentConsentStatusTimestamps` - Tests full POST receipt flow with multiple attributes - Uses different dictionary insertion orders (AMZ vs ZMA) - Verifies only one HTTP request is made (deduplication works) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Remove force unwrap --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Rick van der Linden <rick.vanderlinden@revenuecat.com>
173 lines
4.7 KiB
Swift
173 lines
4.7 KiB
Swift
//
|
|
// Copyright RevenueCat Inc. All Rights Reserved.
|
|
//
|
|
// Licensed under the MIT License (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://opensource.org/licenses/MIT
|
|
//
|
|
// SubscriberAttribute.swift
|
|
//
|
|
// Created by Joshua Liebowitz on 7/1/21.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
struct SubscriberAttribute {
|
|
|
|
let setTime: Date
|
|
let key: String
|
|
let value: String
|
|
var isSynced: Bool
|
|
|
|
/// Whether the `setTime` should be ignored when generating the `individualizedCacheKeyPart`.
|
|
///
|
|
/// If `true`, two attributes with the same `key` and `value` but different `setTime` will be treated as
|
|
/// identical (e.g. to avoid duplicate Post Receipt requests when only `updated_at_ms` differs).
|
|
let ignoreTimeInCacheIdentity: Bool
|
|
|
|
init(
|
|
withKey key: String,
|
|
value: String?,
|
|
isSynced: Bool,
|
|
setTime: Date,
|
|
ignoreTimeInCacheIdentity: Bool = false
|
|
) {
|
|
self.key = key
|
|
self.value = value ?? ""
|
|
self.isSynced = isSynced
|
|
self.setTime = setTime
|
|
self.ignoreTimeInCacheIdentity = ignoreTimeInCacheIdentity
|
|
}
|
|
|
|
init(
|
|
withKey key: String,
|
|
value: String?,
|
|
dateProvider: DateProvider = DateProvider(),
|
|
ignoreTimeInCacheIdentity: Bool = false
|
|
) {
|
|
self.init(
|
|
withKey: key,
|
|
value: value,
|
|
isSynced: false,
|
|
setTime: dateProvider.now(),
|
|
ignoreTimeInCacheIdentity: ignoreTimeInCacheIdentity
|
|
)
|
|
}
|
|
|
|
init(
|
|
attribute: ReservedSubscriberAttribute,
|
|
value: String?,
|
|
dateProvider: DateProvider = DateProvider(),
|
|
ignoreTimeInCacheIdentity: Bool = false
|
|
) {
|
|
self.init(
|
|
withKey: attribute.rawValue,
|
|
value: value,
|
|
dateProvider: dateProvider,
|
|
ignoreTimeInCacheIdentity: ignoreTimeInCacheIdentity
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
extension SubscriberAttribute {
|
|
|
|
init?(dictionary: [String: Any]) {
|
|
guard let key = dictionary[Key.key.rawValue] as? String,
|
|
let isSynced = (dictionary[Key.isSynced.rawValue] as? NSNumber)?.boolValue,
|
|
let setTime = dictionary[Key.setTime.rawValue] as? Date else {
|
|
return nil
|
|
}
|
|
|
|
let value = dictionary[Key.value.rawValue] as? String
|
|
let ignoreTimeInCacheIdentity = (dictionary[Key.ignoreTimeInCacheIdentity.rawValue] as? NSNumber)?.boolValue
|
|
?? false
|
|
|
|
self.init(
|
|
withKey: key,
|
|
value: value,
|
|
isSynced: isSynced,
|
|
setTime: setTime,
|
|
ignoreTimeInCacheIdentity: ignoreTimeInCacheIdentity
|
|
)
|
|
}
|
|
|
|
func asDictionary() -> [String: NSObject] {
|
|
return [Key.key.rawValue: self.key as NSString,
|
|
Key.value.rawValue: self.value as NSString,
|
|
Key.isSynced.rawValue: NSNumber(value: self.isSynced),
|
|
Key.setTime.rawValue: self.setTime as NSDate,
|
|
Key.ignoreTimeInCacheIdentity.rawValue: NSNumber(value: self.ignoreTimeInCacheIdentity)]
|
|
}
|
|
|
|
func asBackendDictionary() -> [String: Any] {
|
|
return [BackendKey.value.rawValue: self.value,
|
|
BackendKey.timestamp.rawValue: self.setTime.millisecondsSince1970]
|
|
}
|
|
|
|
var individualizedCacheKeyPart: String {
|
|
return "[SubscriberAttribute] key: \(self.key) value: \(self.value)" +
|
|
(ignoreTimeInCacheIdentity ? "" : " setTime: \(self.setTime)")
|
|
}
|
|
|
|
}
|
|
|
|
extension SubscriberAttribute: Equatable {}
|
|
|
|
extension SubscriberAttribute: CustomStringConvertible {
|
|
|
|
var description: String {
|
|
return "[SubscriberAttribute] key: \(self.key) value: \(self.value) setTime: \(self.setTime)"
|
|
}
|
|
|
|
}
|
|
|
|
extension SubscriberAttribute {
|
|
|
|
typealias Dictionary = [String: SubscriberAttribute]
|
|
|
|
}
|
|
|
|
extension SubscriberAttribute {
|
|
|
|
static func map(subscriberAttributes: SubscriberAttribute.Dictionary) -> [String: [String: Any]] {
|
|
return subscriberAttributes.mapValues { $0.asBackendDictionary() }
|
|
}
|
|
}
|
|
|
|
extension SubscriberAttribute.Dictionary {
|
|
|
|
var individualizedCacheKeyPart: String {
|
|
// Sort keys to ensure deterministic ordering for cache key computation.
|
|
self.keys.sorted().compactMap { key in
|
|
guard let attribute = self[key] else { return nil }
|
|
return "\(key):\(attribute.individualizedCacheKeyPart)"
|
|
}.joined(separator: ",")
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
extension SubscriberAttribute {
|
|
|
|
private enum Key: String {
|
|
|
|
case key
|
|
case value
|
|
case isSynced
|
|
case setTime
|
|
case ignoreTimeInCacheIdentity
|
|
|
|
}
|
|
|
|
private enum BackendKey: String {
|
|
|
|
case value = "value"
|
|
case timestamp = "updated_at_ms"
|
|
|
|
}
|
|
|
|
}
|