Image showing The 5 Edge Cases That Will Break Your Image Generation API (And How to Test Each One)

The 5 Edge Cases That Will Break Your Image Generation API (And How to Test Each One)

affiliate best offer

[!note] 📚 Smart Assets Manager Series

  1. Why Storage Abstraction Matters
  2. Four Storage Backends, One Interface
  3. The Unified API: Credit Reservation and Rate Limiting
  4. Testing Strategy: Unit Tests vs E2E Tests
  5. The 5 Edge Cases That Break Image APIs ← you are here
  6. API Documentation: Swagger + Postman as Deliverables — April 10

In the previous post, the testing strategy was established: unit tests for function logic, E2E tests for integration behavior. Here are the five specific scenarios where that strategy had to be applied — cases that are invisible in normal usage but surface in production at the worst times.

Each one has a test case. Each one caught a bug or validated a correctness guarantee that unit tests couldn’t provide.


Edge Case 1: Partial Multi-Size Failure — Proportional Credit Charge

The scenario: A user requests 10 image sizes. Seven generate and upload successfully. Three fail due to a generation error. What does the user get charged?

Why it’s hard: The “correct” answer (charge for 7, not 10) requires three services to coordinate correctly: the generator, the storage backend, and the credit service. Mocking any one of them hides whether the coordination is right.

@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
        # Fail on attempts 4, 7, and 9 to simulate intermittent errors
        if call_count in [4, 7, 9]:
            raise RuntimeError(f"Generation failed for {width}x{height}")
        return b"\x89PNG\r\n\x1a\n"  # Minimal valid PNG header

    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: partial success
    data = response.json()
    assert data["success_count"] == 7
    assert data["error_count"] == 3
    # Charge: 0.25 base + 6 additional variants × 0.1 = 0.85
    assert abs(data["credits_used"] - 0.85) < 0.01

Bug it caught: The initial implementation charged the full 10-size cost regardless of failure count. The partial-charge logic was in the wrong place — it ran after the credit confirmation, not before.


Edge Case 2: SVG Injection Attack — Sanitized Output Still Generates

The scenario: A malicious user submits an SVG template containing <script> tags, onclick attributes, and <foreignObject> with embedded iframes. The API should sanitize the SVG and still produce an image — not reject the request entirely.

Why it’s hard: Sanitization that rejects all malicious input is easy. Sanitization that strips the dangerous elements and still renders a valid image requires the sanitizer and the renderer to work correctly in sequence. If the sanitized SVG is malformed, rendering fails. If the renderer receives unsanitized input, the attack succeeds.

@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}"}
    )

    # Generation should succeed — sanitization doesn't mean rejection
    assert response.status_code == 200
    data = response.json()
    assert data["urls"][0]["url"].startswith("data:image/")

    # The sanitization report confirms dangerous elements were removed
    assert data["sanitization"]["elements_removed"] >= 3
    removed_types = data["sanitization"]["elements_removed_types"]
    assert "script" in removed_types
    assert "foreignObject" in removed_types

Bug it caught: The original sanitizer raised an exception when encountering <foreignObject> — it treated nested XML namespaces as malformed rather than stripping them. The test showed that rejection-on-encounter was the wrong behavior; strip-and-continue was correct.


Edge Case 3: Insufficient Credits → 402 Before Generation Starts

The scenario: A user with 0.5 credits requests an operation that costs 1.0 credits. The API should return a 402 error before any generation runs — not after.

Why it matters: If generation runs before the credit check, you deliver the image and then can’t charge for it. If the credit check raises correctly, no computation is wasted.

@pytest.mark.asyncio
async def test_insufficient_credits_returns_402_before_generation(
    async_client, db_session
):
    # Create a user with less than the required credit amount
    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",  # costs 0.25 base
              "generate_sizes": True, "preset_name": "blog_images",  # total cost: 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

Edge Case 4: Server Error → Automatic Credit Refund

The scenario: A user makes a request, credits are reserved, generation starts — and then a server error occurs. The reserved credits should automatically return to the user’s balance.

Why it’s hard: The refund logic must execute in the exception handler. If the exception handler itself raises an exception (or skips the refund in any code path), users permanently lose credits on server errors. This is a trust-destroying bug.

@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

    # Verify credits were fully refunded
    db_session.refresh(test_user)
    assert test_user.credits == initial_credits  # No net charge

Edge Case 5: Rate Limit Enforcement by Tier

The scenario: A free-tier user makes 6 requests in under a minute. The 6th should be rejected with a 429 and a Retry-After header.

Why it requires E2E: Rate limiting state lives in Redis. Unit tests don’t interact with Redis. Only a test that makes real sequential API calls through the full stack can verify that the token bucket state is correctly updated and checked on each request.

@pytest.mark.asyncio
async def test_free_tier_rate_limit_enforced(async_client, free_tier_user):
    # 5 requests should succeed (free tier limit)
    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"Request {i+1} failed unexpectedly: {r.json()}"

    # The 6th request should be rate-limited
    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
    # Retry-After should be between 1 and 60 seconds
    assert 0 < int(r.headers["Retry-After"]) <= 60

The Coverage Result

After these five edge cases (and the basic happy-path tests described in the testing strategy post), the E2E coverage landed at 90% — hitting the target while covering every scenario that has a real production failure mode.

The remaining 10% are scenarios that are genuinely difficult to automate: signed URL expiry validation (depends on real clock behavior), and Cloudinary-specific error responses (hard to simulate without mocking the entire SDK, which defeats the point of E2E tests). These are documented as manual test scenarios.


Key Takeaways

  • The five edge cases worth testing in any API: partial batch failures, injection attacks (sanitize, don’t reject), insufficient credits before generation, server errors triggering refunds, and rate limit state across sequential requests.
  • “Sanitize and continue” is almost always the right behavior for user-supplied template input. Reject-on-encounter creates false negatives for legitimate edge cases.
  • Credit refund logic must be tested with a real server error — not a mock that cooperates. The test should actually trigger an exception in the handler.
  • Rate limit tests require real sequential requests. Redis state is invisible to unit tests.

What’s Next

The system works and is tested. The final piece is making it usable by other developers: Swagger documentation that’s actually informative, and a Postman collection as a first-class deliverable.

→ Next: API Documentation: Swagger + Postman as Deliverables

← Previous: Testing Strategy: Unit Tests vs E2E Tests

— Kékéli

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