Tie verdicts and context uploads to what asked for them

A server outcome is recorded only for the secret the request actually
used. Signing in again, or a switch of Apple account, replaces it under
the same key while a request is still out — so a late 401 could reject
the replacement, and a late success could clear a rejection it never
earned.

The context record is built only while an upload is actually owed.
Correcting away from a deleted account clears that flag deliberately;
without checking it, the engine could still materialize the automatic
fallback and publish it under the deleted choice's timestamp, over a
deliberate choice made elsewhere.

Confirming a context upload re-runs the outbox like an account
confirmation does. Choosing a different context while the previous one
was in flight leaves the new one dirty by design, and nothing was left
to send it.
This commit is contained in:
Arkadiusz Fal
2026-08-28 23:39:00 +02:00
parent 1c0a3e8e9a
commit 8ee0e38fbd
3 changed files with 38 additions and 19 deletions

View File

@@ -27,7 +27,13 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
/// Records the outcome of a server call against the account. The
/// classification lives in one place so the feed cache, which talks to the
/// same servers without going through a provider, reaches the same verdict.
private func recordOutcome(_ error: Error?) {
private func recordOutcome(_ error: Error?, credential: String) {
// Only for the secret the request actually used. Signing in again — or
// a switch of Apple account — replaces it under the same key while a
// request is still out, and a late 401 would otherwise reject the
// replacement, or a late success clear a rejection it never earned.
guard credentialsManager.credential(for: account) == credential else { return }
accountsManager.recordServerOutcome(error, for: account)
}
private let settingsManager: SettingsManager
@@ -63,7 +69,7 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
let (instance, sid) = try await getAuthenticatedInstance()
// Fetch subscriptions from Invidious
let subscriptions = try await tracking {
let subscriptions = try await tracking(credential: sid) {
try await invidiousAPI.subscriptions(instance: instance, sid: sid)
}
@@ -81,7 +87,7 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
let (instance, sid) = try await getAuthenticatedInstance()
// Subscribe on Invidious server
try await tracking {
try await tracking(credential: sid) {
try await invidiousAPI.subscribe(to: channel.id.channelID, instance: instance, sid: sid)
}
@@ -100,7 +106,7 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
// Unsubscribe on Invidious server. Tracked like every other
// authenticated call: a rejected credential noticed here must mark the
// account, or it keeps reporting itself as signed in.
try await tracking {
try await tracking(credential: sid) {
try await invidiousAPI.unsubscribe(from: channelID, instance: instance, sid: sid)
}
@@ -137,13 +143,13 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
/// Gets the authenticated Invidious instance and session ID from account settings.
/// Runs a server call and records what it implies about the credential.
private func tracking<T>(_ operation: () async throws -> T) async rethrows -> T {
private func tracking<T>(credential: String, _ operation: () async throws -> T) async rethrows -> T {
do {
let value = try await operation()
recordOutcome(nil)
recordOutcome(nil, credential: credential)
return value
} catch {
recordOutcome(error)
recordOutcome(error, credential: credential)
throw error
}
}

View File

@@ -27,7 +27,13 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
/// Records the outcome of a server call against the account. The
/// classification lives in one place so the feed cache, which talks to the
/// same servers without going through a provider, reaches the same verdict.
private func recordOutcome(_ error: Error?) {
private func recordOutcome(_ error: Error?, credential: String) {
// Only for the secret the request actually used. Signing in again — or
// a switch of Apple account — replaces it under the same key while a
// request is still out, and a late 401 would otherwise reject the
// replacement, or a late success clear a rejection it never earned.
guard credentialsManager.credential(for: account) == credential else { return }
accountsManager.recordServerOutcome(error, for: account)
}
private let settingsManager: SettingsManager
@@ -63,7 +69,7 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
let (instance, authToken) = try await getAuthenticatedInstance()
// Fetch subscriptions from Piped
let subscriptions = try await tracking {
let subscriptions = try await tracking(credential: authToken) {
try await pipedAPI.subscriptions(instance: instance, authToken: authToken)
}
@@ -83,7 +89,7 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
// Subscribe on Piped server. Tracked like every other authenticated
// call: a rejected credential noticed here must mark the account, or it
// keeps reporting itself as signed in.
try await tracking {
try await tracking(credential: authToken) {
try await pipedAPI.subscribe(channelID: channel.id.channelID, instance: instance, authToken: authToken)
}
@@ -100,7 +106,7 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
let (instance, authToken) = try await getAuthenticatedInstance()
// Unsubscribe on Piped server
try await tracking {
try await tracking(credential: authToken) {
try await pipedAPI.unsubscribe(channelID: channelID, instance: instance, authToken: authToken)
}
@@ -137,13 +143,13 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
/// Gets the authenticated Piped instance and auth token from account settings.
/// Runs a server call and records what it implies about the credential.
private func tracking<T>(_ operation: () async throws -> T) async rethrows -> T {
private func tracking<T>(credential: String, _ operation: () async throws -> T) async rethrows -> T {
do {
let value = try await operation()
recordOutcome(nil)
recordOutcome(nil, credential: credential)
return value
} catch {
recordOutcome(error)
recordOutcome(error, credential: credential)
throw error
}
}