L’API renvoie un code 429, mais le client enchaîne immédiatement trois nouvelles tentatives ; l’utilisateur s’est déconnecté, mais une ancienne requête repart après son délai d’attente. Augmenter le délai d’expiration ne suffit pas à corriger ces dysfonctionnements. Pour valider la couche réseau iOS sur un Mac cloud, il faut d’abord distinguer trois responsabilités : interpréter la réponse, déterminer quand une nouvelle tentative est autorisée et empêcher l’envoi après une annulation. Inutile d’attendre réellement plusieurs dizaines de secondes pour tester ces comportements.
Définir d’abord les requêtes pouvant être retentées
Commencez par les requêtes GET en lecture seule. Cet exemple ne traite que les codes 429 et 503. Prévoyez des stratégies distinctes pour les autres codes de statut et les erreurs de transport, au lieu de regrouper tous les échecs dans une même boucle. L’envoi initial ne compte pas comme une reprise : trois reprises au maximum correspondent donc à quatre envois au maximum.
Pour les requêtes d’écriture, la méthode HTTP ne suffit pas à déterminer si une reprise est sûre. N’envisagez une reprise automatique que si le serveur prend en charge un protocole d’idempotence, si la même opération métier réutilise la même clé d’idempotence et si le corps de la requête peut être relu. Pour un téléversement en flux, vérifiez aussi que le flux peut être recréé.
Un dépassement de délai signifie seulement que le client n’a pas reçu de résultat, pas que le serveur n’a pas terminé l’écriture. Annuler une tâche locale n’annule pas non plus une opération déjà parvenue au serveur.
Conservez un budget de reprises propre à chaque requête logique. Ne réinitialisez pas le compteur à chaque appel de la fonction réseau : si la couche externe et la couche interne effectuent chacune trois reprises, le nombre réel de requêtes s’en trouve multiplié.
Transformer les en-têtes de réponse en contraintes d’attente explicites
Retry-After accepte deux types de valeurs : un nombre entier de secondes positif ou nul, ou une date HTTP. Pour les entiers, n’acceptez que les chiffres ASCII et rejetez les valeurs négatives, les décimales et les dépassements de capacité. Pour les dates, imposez une locale anglaise et le fuseau GMT, sans dépendre de la langue configurée sur le nœud. Voici un exemple pour le format courant IMF-fixdate :
import Foundation
func serverDelay(_ raw: String?, now: Date) -> TimeInterval? {
guard let raw else { return nil }
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty,
text.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }) {
guard let seconds = UInt64(text) else { return nil }
return TimeInterval(seconds)
}
let parser = DateFormatter()
parser.locale = Locale(identifier: "en_US_POSIX")
parser.timeZone = TimeZone(secondsFromGMT: 0)
parser.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
parser.isLenient = false
guard let date = parser.date(from: text),
parser.string(from: date) == text else {
return nil
}
return max(0, date.timeIntervalSince(now))
}
Cet analyseur traite strictement ce format courant ; il n’assure pas une compatibilité complète avec les anciens formats de date HTTP. Si le contrat de l’API impose de les accepter, ajoutez des branches d’analyse et des jeux de données de test dédiés. Dans cet exemple, une valeur invalide entraîne un repli sur la temporisation locale, avec journalisation du motif d’échec de l’analyse.
Fixez now, puis vérifiez séparément les dates passées, présentes et futures. À l’exécution, mesurez l’attente avec une horloge monotone. L’horloge civile sert uniquement à convertir la date en nombre de secondes restantes, afin qu’une correction de l’heure système ne modifie pas une attente déjà commencée.
Calculer la temporisation dans une fonction pure, sans y attendre
Enregistrez le contenu suivant dans retry-check.swift, à la suite de la fonction d’analyse précédente. retryIndex commence à zéro ; les délais de base sont successivement de 1, 2 et 4 secondes. Une variation aléatoire proportionnelle est appliquée, à partir d’une valeur fournie par l’appelant.
enum Decision: Equatable {
case stop
case deferred
case retry(TimeInterval)
}
func decide(
status: Int,
replayable: Bool,
retryIndex: Int,
server: TimeInterval?,
unit: Double
) -> Decision {
guard replayable,
[429, 503].contains(status),
(0..<3).contains(retryIndex),
unit.isFinite,
(0...1).contains(unit) else {
return .stop
}
let base = pow(2.0, Double(retryIndex))
let local = base * (0.5 + 0.5 * unit)
if let server, !server.isFinite || server < 0 {
return .stop
}
let delay = max(local, server ?? 0)
guard delay <= 30 else { return .deferred }
return .retry(delay)
}
precondition(decide(
status: 503, replayable: true,
retryIndex: 0, server: nil, unit: 0
) == .retry(0.5))
precondition(decide(
status: 429, replayable: true,
retryIndex: 1, server: 10, unit: 1
) == .retry(10))
precondition(decide(
status: 429, replayable: true,
retryIndex: 0, server: 60, unit: 0
) == .deferred)
precondition(decide(
status: 503, replayable: true,
retryIndex: 3, server: nil, unit: 1
) == .stop)
precondition(decide(
status: 503, replayable: false,
retryIndex: 0, server: nil, unit: 1
) == .stop)
Exécutez cette commande sur un nœud dont la chaîne d’outils Swift est configurée :
swift retry-check.swift
Une exécution qui se termine normalement indique que les assertions sont vérifiées. N’ajoutez pas d’options d’optimisation qui désactivent ces contrôles. La limite de 30 secondes correspond ici au plafond d’attente automatique choisi pour l’exemple, et non à une exigence du protocole. Si le serveur impose une attente plus longue, renvoyez un état de report ; ne ramenez pas le délai à 30 secondes pour renvoyer la requête trop tôt.
Vérifier les frontières de l’annulation, pas seulement les délais
Le composant d’exécution reçoit uniquement la décision, puis appelle une fonction d’attente injectable. En production, vous pouvez utiliser Task.sleep, qui prend en charge l’annulation. Dans les tests, utilisez un point de suspension pilotable et un signal indiquant que l’attente a commencé, plutôt que de déduire l’avancement de la tâche à partir d’une courte pause.
Vérifiez au minimum ces trois enchaînements :
- Annulation avant l’attente : le nombre d’envois n’augmente pas.
- Annulation pendant l’attente : l’erreur d’annulation levée par l’attente est propagée à l’appelant et ne déclenche pas une reprise pour échec ordinaire.
- Annulation juste à la fin de l’attente : l’état d’annulation de la tâche et la génération de session sont vérifiés à nouveau avant l’envoi.
La génération de session est un identifiant incrémenté à chaque changement d’état de connexion. Même si une ancienne requête reçoit une réponse positive, elle doit comparer les générations avant de mettre à jour l’interface ou le cache. Une condition de concurrence subsiste entre la vérification de l’annulation et l’envoi effectif : on ne peut donc pas affirmer que l’annulation garantit qu’aucune requête ne parviendra au serveur.
Faire des scénarios d’échec des critères de validation avant fusion
Complétez les tests de l’analyseur avec une valeur absente, des espaces blancs, des nombres négatifs, des décimales, des entiers trop grands et les cas limites des dates. Pour l’ordonnanceur, couvrez les indices de reprise de zéro à trois, les bornes zéro et un de l’entrée aléatoire, ainsi que les délais serveur égaux au plafond ou supérieurs à celui-ci.
Pour les tests d’intégration, utilisez une doublure de transport scriptée qui renvoie successivement 503, 429 et 200, puis vérifiez le délai prévu avant chaque envoi. Ajoutez des scénarios d’annulation, de corps non rejouable et de budget épuisé. Ne provoquez pas de limitation de débit sur une véritable API publique et ne faites pas réellement attendre l’environnement de test pendant la durée indiquée par l’en-tête de réponse.
Ne conservez dans les journaux que l’identifiant de la requête logique, le code de statut, l’indice de reprise, le délai prévu et le motif d’arrêt. N’enregistrez ni les en-têtes d’authentification ni le corps des requêtes. La validation finale porte sur quatre points : aucun renvoi prématuré, aucun envoi au-delà du budget, aucune annulation traitée comme une erreur ordinaire et aucune requête non rejouable engagée dans la boucle. Une fois ces garanties établies, vous pourrez examiner l’opportunité d’ajuster les paramètres de temporisation.
Questions fréquentes
Faut-il relancer toute requête recevant un statut 429 ou 503 ?
Non. Vérifiez d'abord que la requête peut être rejouée sans risque, que le budget reste disponible et que le délai serveur est respecté. Une écriture exige un protocole d'idempotence côté serveur et un corps rejouable.
Peut-on raccourcir Retry-After pour respecter son plafond ?
Non, cela provoquerait un envoi trop tôt. Renvoyez une décision de report à la couche appelante, qui pourra conserver la tâche ou informer l'utilisateur.
Comment tester le backoff sans attendre réellement ?
Injectez l'horloge, la valeur aléatoire et la fonction d'attente. Comparez directement les délais calculés, puis annulez à un point de suspension contrôlé et vérifiez que le nombre d'envois n'augmente pas.
Besoin d’un Mac dédié pour effectuer cette compilation ?
Choisissez la durée de location selon vos besoins. Profitez d’un Mac physique entièrement dédié, avec macOS accessible en interface graphique et en ligne de commande. Les modèles, les régions et les disponibilités sont ceux indiqués en temps réel dans la console.