Image showing Les 5 cas limites qui cassent les APIs de génération d'images (trouvés en production, testés dans le code)

Les 5 cas limites qui cassent les APIs de génération d'images (trouvés en production, testés dans le code)

affiliate best offer

[!note] 📚 Série Smart Assets Manager

  1. Pourquoi l’abstraction du stockage est essentielle — 11 mai
  2. Quatre backends, une interface — 18 mai
  3. L’API unifiée : crédits et limitation de débit — 27 avril
  4. Stratégie de tests : unitaires vs E2E — 20 avril
  5. 5 cas limites qui cassent les APIs d’images ← vous êtes ici
  6. Documentation API : Swagger + Postman — 30 mars

Le post sur la stratégie de tests a établi le principe : tests unitaires pour la logique des fonctions, tests E2E pour le comportement d’intégration. Ce post présente cinq cas spécifiques où ce principe a dû être appliqué — des scénarios qui semblent corrects en usage normal mais qui se manifestent en production dans des conditions difficiles à prévoir et coûteuses à déboguer.

Aucun de ces cas n’est théorique. Chacun représente soit un bug détecté par un test, soit une garantie que le test valide sur l’ensemble de la pile.


Cas 1 : Échec partiel de batch → facturation proportionnelle

Quand un utilisateur demande 10 tailles d’images et que 3 échouent en cours de génération, que facture l’API ?

La réponse évidente — facturer pour 7 succès, pas 10 — nécessite que trois services se coordonnent correctement : le générateur, le backend de stockage et le service de crédits. Le générateur sait combien d’images ont réussi. Le service de crédits conserve la réservation. Pour que la facturation reflète la réalité, ces deux informations doivent se retrouver au bon endroit, dans le bon ordre.

L’implémentation initiale l’avait mal fait. La confirmation de crédit s’exécutait après la boucle de batch, en utilisant le nombre demandé plutôt que le nombre de succès. La logique était au bon endroit conceptuellement, mais lisait la mauvaise variable.

@pytest.mark.asyncio
async def test_partial_failure_proportional_credit_charge(
    async_client, test_user, monkeypatch
):
    call_count = 0

    def mock_generator_with_failures(data, width, height):
        nonlocal call_count
        call_count += 1
        # Échoue aux tentatives 4, 7 et 9 — simule des erreurs intermittentes
        if call_count in [4, 7, 9]:
            raise RuntimeError(f"Generation failed for {width}x{height}")
        return b"\x89PNG\r\n\x1a\n"  # En-tête PNG minimal valide

    monkeypatch.setattr(
        "app.services.generators.SocialCardGenerator.generate",
        mock_generator_with_failures
    )

    response = await async_client.post(
        "/api/v1/deterministic/generate",
        json={
            "type": "social_card",
            "storage": "direct",
            "generate_sizes": True,
            "preset_name": "custom_10",
            "data": {"title": "Test", "brand_color": "#000"},
        },
        headers={"Authorization": f"Bearer {test_user.api_key}"},
    )

    assert response.status_code == 207  # Multi-status : succès partiel
    data = response.json()
    assert data["success_count"] == 7
    assert data["error_count"] == 3
    # Facturation : 0,25 de base + 6 variantes supplémentaires × 0,1 = 0,85
    assert abs(data["credits_used"] - 0.85) < 0.01

Le test utilise monkeypatch pour injecter des échecs déterministes à des positions spécifiques. L’assertion sur credits_used est celle qui a détecté le bug — quand la facturation était calculée à partir du nombre demandé, elle était toujours 1,15, pas 0,85.

À retenir : La facturation proportionnelle exige que le nombre de succès parvienne au système de facturation. Testez ceci avec des échecs injectés à des positions spécifiques dans le batch, puis vérifiez que la facturation correspond aux succès.


Cas 2 : Injection SVG → assainir, ne pas rejeter

Celui-ci implique une question de sécurité avec une réponse correcte non évidente.

Smart Assets Manager accepte des templates SVG fournis par l’utilisateur. Un utilisateur malveillant peut soumettre un SVG contenant des balises <script>, des gestionnaires onclick et des éléments <foreignObject> encapsulant des iframes avec des URIs javascript:. La mauvaise réponse est de rejeter la requête. La bonne réponse est de supprimer les éléments dangereux et de quand même rendre l’image.

Pourquoi ? Parce que rejeter à la première rencontre est trop agressif. De vrais SVGs issus d’outils légitimes contiennent parfois des déclarations d’espaces de noms, des instructions de traitement conditionnelles ou des éléments que les parseurs signalent comme suspects mais qui ne constituent pas de vraies attaques. Rejeter ces entrées crée des faux négatifs et frustre les utilisateurs légitimes.

@pytest.mark.asyncio
async def test_svg_injection_sanitized_and_renders(async_client, test_user):
    malicious_svg = """<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">
        <script>document.cookie = 'stolen=' + document.cookie;</script>
        <rect onclick="alert('xss')" width="800" height="600" fill=""/>
        <foreignObject><div xmlns="http://www.w3.org/1999/xhtml">
            <iframe src="javascript:alert('xss')"></iframe>
        </div></foreignObject>
        <text x="400" y="300"></text>
    </svg>"""

    response = await async_client.post(
        "/api/v1/deterministic/generate",
        json={
            "type": "svg_template",
            "storage": "direct",
            "data": {
                "template_content": malicious_svg,
                "variables": {"bg_color": "#1A2980", "title": "Safe"},
            },
        },
        headers={"Authorization": f"Bearer {test_user.api_key}"},
    )

    # La génération réussit — l'assainissement supprime, ne bloque pas
    assert response.status_code == 200
    data = response.json()
    assert data["urls"][0]["url"].startswith("data:image/")

    # Le rapport d'assainissement fait partie de la réponse
    assert data["sanitization"]["elements_removed"] >= 3
    removed_types = data["sanitization"]["elements_removed_types"]
    assert "script" in removed_types
    assert "foreignObject" in removed_types

Le test vérifie deux choses qui tirent dans des directions opposées : la réponse est 200 (pas rejetée), et les éléments dangereux ont été supprimés (pas transmis). Les deux doivent être vrais simultanément.

Le bug détecté : le sanitizer original levait une exception en rencontrant des éléments <foreignObject> — il traitait les espaces de noms XML imbriqués comme un SVG malformé plutôt que de les supprimer. Le correctif consistait à ajouter foreignObject à la liste de suppression et à continuer le rendu, pas à le traiter comme une erreur de parsing.


Cas 3 et 4 : Crédits — avant et après

Deux cas limites liés aux crédits vont ensemble parce qu’ils testent des modes d’échec opposés : l’API surfacturant en démarrant avant le moment approprié, et l’API perdant de l’argent en ne récupérant pas après une erreur serveur.

Cas 3 : Crédits insuffisants → 402 avant que la génération commence

Si la vérification des crédits se produit après la génération, vous livrez l’image sans pouvoir la facturer. La vérification doit être en premier — avant tout calcul.

@pytest.mark.asyncio
async def test_insufficient_credits_returns_402_before_generation(
    async_client, db_session
):
    poor_user = User(email="[email protected]", credits=0.5, api_key="poor-key")
    db_session.add(poor_user)
    db_session.commit()

    response = await async_client.post(
        "/api/v1/deterministic/generate",
        json={
            "type": "social_card",
            "storage": "direct",
            "generate_sizes": True,
            "preset_name": "blog_images",  # Coût total : 1,75
            "data": {"title": "Test"},
        },
        headers={"Authorization": "Bearer poor-key"},
    )

    assert response.status_code == 402
    data = response.json()
    assert data["detail"]["error"] == "insufficient_credits"
    assert data["detail"]["available"] == 0.5
    assert data["detail"]["required"] == 1.75

Le test vérifie que le corps de la réponse inclut à la fois le solde disponible et le montant requis — donnant à l’appelant suffisamment d’informations pour expliquer l’erreur à l’utilisateur sans un second appel API.

Cas 4 : Erreur serveur → remboursement automatique des crédits

Les crédits sont réservés avant le début de la génération (la réservation atomique décrite dans le post 3). Si une erreur serveur survient en cours de génération, ces crédits réservés doivent retourner dans le solde de l’utilisateur. C’est un bug qui détruit la confiance s’il échoue silencieusement.

@pytest.mark.asyncio
async def test_server_error_triggers_credit_refund(
    async_client, test_user, monkeypatch, db_session
):
    initial_credits = test_user.credits

    def mock_generation_failure(*args, **kwargs):
        raise RuntimeError("Simulated server error during generation")

    monkeypatch.setattr(
        "app.services.generators.SocialCardGenerator.generate",
        mock_generation_failure
    )

    response = await async_client.post(
        "/api/v1/deterministic/generate",
        json={"type": "social_card", "storage": "direct", "data": {"title": "Test"}},
        headers={"Authorization": f"Bearer {test_user.api_key}"},
    )

    assert response.status_code == 500

    # Le solde de l'utilisateur doit être inchangé — aucune facturation nette en cas d'erreur serveur
    db_session.refresh(test_user)
    assert test_user.credits == initial_credits

Le schéma critique ici est db_session.refresh(test_user) — sans actualisation depuis la base de données, l’objet en mémoire conserve l’état avant remboursement. Le test passe par la couche HTTP (sans mocker directement le endpoint) car la logique de remboursement doit s’exécuter dans le gestionnaire d’erreurs, pas dans la fixture de test.

[!info] Le schéma de test de remboursement Pour tester les remboursements de crédits, injectez une erreur dans la couche de génération (pas dans la couche de crédits), effectuez un vrai appel HTTP, puis actualisez l’objet base de données et vérifiez que le solde est inchangé. Tout raccourci qui contourne la couche HTTP contourne également le gestionnaire d’erreurs où vit le remboursement.


Cas 5 : État de limitation de débit sur des requêtes séquentielles

La limitation de débit est l’exemple le plus clair d’un test uniquement E2E. L’état du token bucket vit dans Redis. Les tests unitaires ne touchent pas Redis. Seules des requêtes réelles et séquentielles à travers la pile complète peuvent vérifier que le limiteur de débit compte correctement et impose les limites là où il le devrait.

@pytest.mark.asyncio
async def test_free_tier_rate_limit_enforced(async_client, free_tier_user):
    # Le niveau gratuit autorise 5 requêtes par minute — toutes doivent réussir
    for i in range(5):
        r = await async_client.post(
            "/api/v1/deterministic/generate",
            json={
                "type": "url_personalization",
                "storage": "direct",
                "data": {
                    "text": f"Test {i}",
                    "background_url": "https://example.com/bg.jpg",
                },
            },
            headers={"Authorization": f"Bearer {free_tier_user.api_key}"},
        )
        assert r.status_code == 200, f"Requête {i+1} a échoué inopinément : {r.json()}"

    # La 6ème requête atteint la limite
    r = await async_client.post(
        "/api/v1/deterministic/generate",
        json={
            "type": "url_personalization",
            "storage": "direct",
            "data": {"text": "Over limit", "background_url": "https://example.com/bg.jpg"},
        },
        headers={"Authorization": f"Bearer {free_tier_user.api_key}"},
    )

    assert r.status_code == 429
    assert "Retry-After" in r.headers
    assert 0 < int(r.headers["Retry-After"]) <= 60

Deux assertions sur la réponse 429 : le code de statut et l’en-tête Retry-After. L’en-tête est vérifié par plage (entre 1 et 60 secondes) plutôt que par valeur exacte — parce que le temps de rechargement du bucket du limiteur est relatif au moment où le test s’exécute, pas un nombre fixe. Vérifier une plage est le comportement correct ici ; vérifier une valeur exacte rendrait le test dépendant du timing et instable.


Le résultat : 90 % de couverture E2E

Après ces cinq cas — plus les tests de base du chemin heureux du post sur la stratégie de tests — la couverture E2E a atteint 90 %, correspondant à la cible. Les 10 % restants représentent des scénarios genuinement difficiles à automatiser : la validation de l’expiration des URLs signées (dépend du comportement réel de l’horloge d’une façon qui déjoue les environnements de test) et les réponses d’erreur spécifiques à Cloudinary (les simuler sans mocker tout le SDK annule l’intérêt des tests E2E). Les deux sont documentés comme scénarios de test manuels.

Les cinq cas limites ici partagent un schéma : ce sont tous des scénarios où le comportement à travers les limites de composants est ce qui doit être testé, pas la logique d’une fonction individuelle. Le générateur ne connaît pas les remboursements de crédits. Le service de crédits ne connaît pas la limitation de débit. Seul un test qui exercice la pile complète détecte les échecs de coordination.

→ Suivant : Documentation API : Swagger + Postman comme livrables de première classe

Full Bright

Full Bright

A professional and sympathic business man.

Contact

Contact Us

To order one of our services, navigate to the order service page

Address

10 rue François 1er,
75008 Paris

Email Us

hello at bright-softwares dot com

Open Hours

Monday - Friday
9:00AM - 05:00PM