Repository: softwareone-platform/ffc-finops-operations
Pull request #136: feat: Add a command to fetch the latest currency conversion rates and store them in the DB (MPT-8063)
Author: login: arturbalabanov
Created: 2025-03-26T16:04:41Z | Merged: 2025-03-27T12:23:09Z

I definitely went overboard with the changes here 😅 

Also includes some refactoring to the mock clients, little by little testing external APIs will become easier and easier :)

Also, intentionally didn't include the currency conversion itself in this PR as it's quite large as it is. Will open a separate ticket for that


Diff:
diff --git a/app/api_clients/exchange_rate.py b/app/api_clients/exchange_rate.py
new file mode 100644
index 00000000..4e77e5be
--- /dev/null
+++ b/app/api_clients/exchange_rate.py
@@ -0,0 +1,83 @@
+import enum
+
+import httpx
+import stamina
+
+from app.api_clients.base import APIClientError, BaseAPIClient
+
+
+class ExchangeRateAPIErrorType(str, enum.Enum):
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+
+    UNSUPPORTED_CODE = "unsupported-code"
+    MALFORMED_REQUEST = "malformed-request"
+    INVALID_KEY = "invalid-key"
+    INACTIVE_ACCOUNT = "inactive-account"
+    QUOTA_REACHED = "quota-reached"
+    UNKNOWN = "unknown"
+
+    @classmethod
+    def _missing_(cls, value):
+        return cls.UNKNOWN
+
+
+class ExchangeRateAPIError(APIClientError):
+    # Exchange Rate API can return responses with a "result" field that is not "success"
+    # while still being 200 OK responses
+    #
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+    response: httpx.Response
+    response_result: str | None
+    error_type: ExchangeRateAPIErrorType
+
+    def __init__(self, response: httpx.Response):
+        self.response = response
+
+        try:
+            data = response.json()
+            self.response_result = data.get("result")
+            self.error_type = ExchangeRateAPIErrorType(data.get("error-type", "unknown"))
+        except ValueError:  # pragma: no cover
+            self.response_result = None
+            self.error_type = ExchangeRateAPIErrorType.UNKNOWN
+
+        super().__init__(f"response result is {self.response_result}, code: {self.error_type}")
+
+    def should_retry(self) -> bool:
+        return self.error_type == ExchangeRateAPIErrorType.UNKNOWN
+
+
+class ExchangeRateAPIClient(BaseAPIClient):
+    auth = None  # Implemented in base_url
+
+    @property
+    def base_url(self):
+        return f"{self.settings.exchange_rate_api_base_url}/{self.settings.exchange_rate_api_token}"
+
+    @staticmethod
+    def retry_condition(exc: Exception) -> bool:
+        # If the error is an HTTP status error, only retry on 5xx errors.
+        if isinstance(exc, httpx.HTTPStatusError):
+            return exc.response.status_code >= 500
+
+        # "successful" responses (i.e. status_code < 400) can still have errors
+        # that we may want to retry on if it makes sense
+        if isinstance(exc, ExchangeRateAPIError):
+            return exc.should_retry()
+
+        # Retry on all other httpx errors (e.g. timeouts)
+        return isinstance(exc, httpx.HTTPError)
+
+    @stamina.retry(on=retry_condition, attempts=3)
+    async def get_latest_rates(self, base_currency: str) -> httpx.Response:
+        response = await self.httpx_client.get(f"/latest/{base_currency}")
+        response.raise_for_status()
+        try:
+            response_data = response.json()
+        except ValueError:  # pragma: no cover
+            raise ExchangeRateAPIError(response)
+
+        if response_data.get("result") != "success":
+            raise ExchangeRateAPIError(response)
+
+        return response
diff --git a/app/cli.py b/app/cli.py
index 9e955694..7666fc41 100644
--- a/app/cli.py
+++ b/app/cli.py
@@ -1,6 +1,8 @@
 import inspect
+import logging
 
 import typer
+from rich.logging import RichHandler
 
 from app import commands
 from app.conf import get_settings
@@ -25,3 +27,11 @@ def main(
     ctx: typer.Context,
 ):
     ctx.obj = get_settings()
+
+    if ctx.obj.cli_rich_logging:
+        logging.basicConfig(
+            level=logging.INFO,
+            format="%(message)s",
+            datefmt="%X",
+            handlers=[RichHandler()],
+        )
diff --git a/app/commands/__init__.py b/app/commands/__init__.py
index fc71e4fb..e0299bfd 100644
--- a/app/commands/__init__.py
+++ b/app/commands/__init__.py
@@ -6,6 +6,7 @@
     openapi,
     redeem_entitlements,
     update_current_month_datasource_expenses,
+    update_latest_exchange_rates,
 )
 
 __all__ = [
@@ -16,4 +17,5 @@
     "redeem_entitlements",
     "calculate_accounts_stats",
     "update_current_month_datasource_expenses",
+    "update_latest_exchange_rates",
 ]
diff --git a/app/commands/update_latest_exchange_rates.py b/app/commands/update_latest_exchange_rates.py
new file mode 100644
index 00000000..d649a1d5
--- /dev/null
+++ b/app/commands/update_latest_exchange_rates.py
@@ -0,0 +1,57 @@
+import asyncio
+import logging
+
+import typer
+from sqlalchemy.ext.asyncio import AsyncEngine
+
+from app.api_clients.exchange_rate import ExchangeRateAPIClient
+from app.conf import Settings
+from app.db.base import get_db_engine, get_sessionmaker
+from app.db.handlers import ExchangeRatesHandler
+from app.db.models import ExchangeRates
+
+logger = logging.getLogger(__name__)
+
+
+async def main(db_engine: AsyncEngine, settings: Settings) -> None:
+    sessionmaker = get_sessionmaker(db_engine)
+
+    async with sessionmaker() as session:
+        exchange_rates_handler = ExchangeRatesHandler(session)
+
+        async with session.begin():
+            latest_exchange_rates = await exchange_rates_handler.fetch_latest_valid()
+
+            if latest_exchange_rates is not None:
+                logger.info("Exchange rates are already stored in the database and are still valid")
+                logger.info("Skipping fetching exchange rates")
+                return
+
+            logger.info("Exchange rates are not stored in the database or are no longer valid")
+
+        logger.info("Fetching latest exchange rates against USD")
+        async with ExchangeRateAPIClient(settings) as exchange_rate_client:
+            try:
+                response = await exchange_rate_client.get_latest_rates(base_currency="USD")
+            except Exception as e:
+                logger.exception("Failed to fetch exchange rates due to an exception")
+                raise e
+
+            exchange_rates = response.json()
+
+        logger.info("Successfully fetched exchange rates")
+
+        logger.info("Storing latest exchange rates into the database")
+        async with session.begin():
+            exchange_rates_model = ExchangeRates(api_response=exchange_rates)
+            session.add(exchange_rates_model)
+
+        logger.info("Completed storing exchange rates")
+
+
+def command(ctx: typer.Context) -> None:
+    """Fetch exahnge rates from the exchange rate API and store them in the database"""
+    db_engine = get_db_engine(ctx.obj)
+    logger.info("Starting command function")
+    asyncio.run(main(db_engine, ctx.obj))
+    logger.info("Completed command function")
diff --git a/app/conf.py b/app/conf.py
index 78fc7d77..1aa4e627 100644
--- a/app/conf.py
+++ b/app/conf.py
@@ -39,6 +39,10 @@ class Settings(BaseSettings):
     optscale_rest_api_base_url: str
     optscale_cluster_secret: str
 
+    exchange_rate_api_base_url: str
+    exchange_rate_api_token: str
+
+    cli_rich_logging: bool = True
     debug: bool = False
 
     @computed_field
diff --git a/app/db/handlers.py b/app/db/handlers.py
index 5d495b7d..a8658273 100644
--- a/app/db/handlers.py
+++ b/app/db/handlers.py
@@ -7,7 +7,7 @@
 from uuid import UUID
 
 import sqlalchemy
-from sqlalchemy import ColumnExpressionArgument, Select, func, select, update
+from sqlalchemy import ColumnExpressionArgument, Select, desc, func, select, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import joinedload
@@ -21,6 +21,7 @@
     ChargesFile,
     DatasourceExpense,
     Entitlement,
+    ExchangeRates,
     Organization,
     System,
     TimestampMixin,
@@ -493,3 +494,18 @@ def __init__(self, session):
         self.default_options = [
             joinedload(ChargesFile.owner),
         ]
+
+
+class ExchangeRatesHandler(ModelHandler[ExchangeRates]):
+    async def fetch_latest_valid(self) -> ExchangeRates | None:
+        scalars = await self.query_db(
+            select(ExchangeRates)
+            .filter(ExchangeRates.next_update > datetime.now(UTC))
+            .order_by(desc(ExchangeRates.last_update))
+            .limit(1)
+        )
+
+        if not scalars:
+            return None
+
+        return scalars[0]
diff --git a/app/db/models.py b/app/db/models.py
index 69264f84..e1195197 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -4,7 +4,17 @@
 from decimal import Decimal
 
 import sqlalchemy as sa
-from sqlalchemy import Enum, ForeignKey, Index, Integer, String, Text, UniqueConstraint
+from sqlalchemy import (
+    Enum,
+    ForeignKey,
+    Index,
+    Integer,
+    String,
+    Text,
+    UniqueConstraint,
+)
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.orm import (
     DeclarativeBase,
     Mapped,
@@ -276,7 +286,7 @@ class Organization(Base, AuditableMixin, HumanReadablePKMixin):
     )
 
 
-class DatasourceExpense(Base, HumanReadablePKMixin, AuditableMixin):
+class DatasourceExpense(Base, HumanReadablePKMixin, TimestampMixin):
     __tablename__ = "datasource_expenses"
 
     PK_PREFIX = "FDSX"
@@ -305,6 +315,48 @@ class DatasourceExpense(Base, HumanReadablePKMixin, AuditableMixin):
     )
 
 
+class ExchangeRates(Base, HumanReadablePKMixin, TimestampMixin):
+    __tablename__ = "exchange_rates"
+
+    PK_PREFIX = "FEXR"
+    PK_NUM_LENGTH = 12
+
+    api_response: Mapped[dict] = mapped_column(JSONB(), nullable=False)
+
+    @hybrid_property
+    def base_currency(self) -> str:
+        return self.api_response["base_code"]
+
+    @base_currency.inplace.expression
+    @classmethod
+    def _base_currency_sql_expr(cls) -> sa.ColumnElement[str]:
+        return cls.api_response["base_code"].astext
+
+    @hybrid_property
+    def last_update(self) -> datetime.datetime:
+        last_update_unix = self.api_response["time_last_update_unix"]
+        return datetime.datetime.fromtimestamp(last_update_unix, datetime.UTC)
+
+    @last_update.inplace.expression
+    @classmethod
+    def _last_update_sql_expr(cls) -> sa.ColumnElement[datetime.datetime]:
+        return sa.func.to_timestamp(
+            sa.cast(cls.api_response["time_last_update_unix"].astext, sa.Integer)
+        )
+
+    @hybrid_property
+    def next_update(self) -> datetime.datetime:
+        next_update_unix = self.api_response["time_next_update_unix"]
+        return datetime.datetime.fromtimestamp(next_update_unix, datetime.UTC)
+
+    @next_update.inplace.expression
+    @classmethod
+    def _next_update_sql_expr(cls) -> sa.ColumnElement[datetime.datetime]:
+        return sa.func.to_timestamp(
+            sa.cast(cls.api_response["time_next_update_unix"].astext, sa.Integer)
+        )
+
+
 class Entitlement(Base, HumanReadablePKMixin, AuditableMixin):
     __tablename__ = "entitlements"
 
diff --git a/env.example b/env.example
index 8786e8ff..6225e5d9 100644
--- a/env.example
+++ b/env.example
@@ -12,3 +12,6 @@ FFC_OPERATIONS_OPTSCALE_REST_API_BASE_URL=https://opt-api.ffc.com/restapi/v2
 FFC_OPERATIONS_OPTSCALE_CLUSTER_SECRET=test_cluster_secret
 FFC_OPERATIONS_AUTH_ACCESS_JWT_SECRET=auth_access_jwt_secret
 FFC_OPERATIONS_AUTH_REFRESH_JWT_SECRET=auth_refresh_jwt_secret
+FFC_OPERATIONS_EXCHANGE_RATE_API_BASE_URL=https://v6.exchangerate-api.com/v6
+FFC_OPERATIONS_EXCHANGE_RATE_API_TOKEN=my_exchange_rate_api_token
+FFC_OPERATIONS_CLI_RICH_LOGGING=True
diff --git a/migrations/versions/09b830373981_remove_unneeded_audit_fields_from_models.py b/migrations/versions/09b830373981_remove_unneeded_audit_fields_from_models.py
new file mode 100644
index 00000000..d44a5b51
--- /dev/null
+++ b/migrations/versions/09b830373981_remove_unneeded_audit_fields_from_models.py
@@ -0,0 +1,53 @@
+"""Remove unneeded audit fields from models
+
+Revision ID: 09b830373981
+Revises: 5da9a1a6efaa
+Create Date: 2025-03-27 10:15:18.020562
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import sqlalchemy_utils
+
+
+# revision identifiers, used by Alembic.
+revision: str = '09b830373981'
+down_revision: Union[str, None] = '5da9a1a6efaa'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_constraint('fk_datasource_expenses_created_by_actors', 'datasource_expenses', type_='foreignkey')
+    op.drop_constraint('fk_datasource_expenses_updated_by_actors', 'datasource_expenses', type_='foreignkey')
+    op.drop_constraint('fk_datasource_expenses_deleted_by_actors', 'datasource_expenses', type_='foreignkey')
+    op.drop_column('datasource_expenses', 'updated_by')
+    op.drop_column('datasource_expenses', 'created_by')
+    op.drop_column('datasource_expenses', 'deleted_by')
+    op.drop_constraint('fk_exchange_rates_created_by_actors', 'exchange_rates', type_='foreignkey')
+    op.drop_constraint('fk_exchange_rates_updated_by_actors', 'exchange_rates', type_='foreignkey')
+    op.drop_constraint('fk_exchange_rates_deleted_by_actors', 'exchange_rates', type_='foreignkey')
+    op.drop_column('exchange_rates', 'updated_by')
+    op.drop_column('exchange_rates', 'created_by')
+    op.drop_column('exchange_rates', 'deleted_by')
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('exchange_rates', sa.Column('deleted_by', sa.VARCHAR(), autoincrement=False, nullable=True))
+    op.add_column('exchange_rates', sa.Column('created_by', sa.VARCHAR(), autoincrement=False, nullable=True))
+    op.add_column('exchange_rates', sa.Column('updated_by', sa.VARCHAR(), autoincrement=False, nullable=True))
+    op.create_foreign_key('fk_exchange_rates_deleted_by_actors', 'exchange_rates', 'actors', ['deleted_by'], ['id'])
+    op.create_foreign_key('fk_exchange_rates_updated_by_actors', 'exchange_rates', 'actors', ['updated_by'], ['id'])
+    op.create_foreign_key('fk_exchange_rates_created_by_actors', 'exchange_rates', 'actors', ['created_by'], ['id'])
+    op.add_column('datasource_expenses', sa.Column('deleted_by', sa.VARCHAR(), autoincrement=False, nullable=True))
+    op.add_column('datasource_expenses', sa.Column('created_by', sa.VARCHAR(), autoincrement=False, nullable=True))
+    op.add_column('datasource_expenses', sa.Column('updated_by', sa.VARCHAR(), autoincrement=False, nullable=True))
+    op.create_foreign_key('fk_datasource_expenses_deleted_by_actors', 'datasource_expenses', 'actors', ['deleted_by'], ['id'])
+    op.create_foreign_key('fk_datasource_expenses_updated_by_actors', 'datasource_expenses', 'actors', ['updated_by'], ['id'])
+    op.create_foreign_key('fk_datasource_expenses_created_by_actors', 'datasource_expenses', 'actors', ['created_by'], ['id'])
+    # ### end Alembic commands ###
diff --git a/migrations/versions/5da9a1a6efaa_merge_d144491ac1b6_and_f4b7143100d4.py b/migrations/versions/5da9a1a6efaa_merge_d144491ac1b6_and_f4b7143100d4.py
new file mode 100644
index 00000000..0ed6347a
--- /dev/null
+++ b/migrations/versions/5da9a1a6efaa_merge_d144491ac1b6_and_f4b7143100d4.py
@@ -0,0 +1,27 @@
+"""merge d144491ac1b6 and f4b7143100d4
+
+Revision ID: 5da9a1a6efaa
+Revises: d144491ac1b6, f4b7143100d4
+Create Date: 2025-03-27 10:14:52.571147
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import sqlalchemy_utils
+
+
+# revision identifiers, used by Alembic.
+revision: str = '5da9a1a6efaa'
+down_revision: Union[str, None] = ('d144491ac1b6', 'f4b7143100d4')
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    pass
+
+
+def downgrade() -> None:
+    pass
diff --git a/migrations/versions/d144491ac1b6_create_exchange_rates_model.py b/migrations/versions/d144491ac1b6_create_exchange_rates_model.py
new file mode 100644
index 00000000..057de654
--- /dev/null
+++ b/migrations/versions/d144491ac1b6_create_exchange_rates_model.py
@@ -0,0 +1,46 @@
+"""create exchange rates model
+
+Revision ID: d144491ac1b6
+Revises: 3679d9919a68
+Create Date: 2025-03-25 14:42:41.580139
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import sqlalchemy_utils
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = 'd144491ac1b6'
+down_revision: Union[str, None] = '3679d9919a68'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('exchange_rates',
+    sa.Column('api_response', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
+    sa.Column('id', sa.String(), nullable=False),
+    sa.Column('created_by', sa.String(), nullable=True),
+    sa.Column('updated_by', sa.String(), nullable=True),
+    sa.Column('deleted_by', sa.String(), nullable=True),
+    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
+    sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
+    sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
+    sa.ForeignKeyConstraint(['created_by'], ['actors.id'], ),
+    sa.ForeignKeyConstraint(['deleted_by'], ['actors.id'], ),
+    sa.ForeignKeyConstraint(['updated_by'], ['actors.id'], ),
+    sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index(op.f('ix_exchange_rates_id'), 'exchange_rates', ['id'], unique=True)
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index(op.f('ix_exchange_rates_id'), table_name='exchange_rates')
+    op.drop_table('exchange_rates')
+    # ### end Alembic commands ###
diff --git a/pyproject.toml b/pyproject.toml
index 91c9f2d7..ca3dcd06 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,6 +30,9 @@ dependencies = [
     "email-validator==2.2.*",
     "pycountry>=24.6.1,<24.7",
     "requela==0.1.*",
+    # stamina uses calendar versioning meaning only the latest one is officially supported
+    # ref: https://github.com/hynek/stamina/blob/main/.github/SECURITY.md
+    "stamina>=25.1.0",
 ]
 
 [dependency-groups]
diff --git a/tests/commands/test_update_current_month_datasource_expenses.py b/tests/commands/test_update_current_month_datasource_expenses.py
index 5477fe59..0faf9691 100644
--- a/tests/commands/test_update_current_month_datasource_expenses.py
+++ b/tests/commands/test_update_current_month_datasource_expenses.py
@@ -17,7 +17,7 @@
 from app.db.handlers import DatasourceExpenseHandler
 from app.db.models import DatasourceExpense, Organization
 from app.enums import OrganizationStatus
-from tests.conftest import MockOptscaleClient
+from tests.fixtures.mock_api_clients import MockOptscaleClient
 from tests.types import ModelFactory
 
 
diff --git a/tests/commands/test_update_latest_exchange_rates.py b/tests/commands/test_update_latest_exchange_rates.py
new file mode 100644
index 00000000..6b14092b
--- /dev/null
+++ b/tests/commands/test_update_latest_exchange_rates.py
@@ -0,0 +1,241 @@
+import logging
+from datetime import UTC, datetime, timedelta
+
+import pytest
+import time_machine
+from pytest_mock import MockerFixture
+from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
+from typer.testing import CliRunner
+
+from app.api_clients.exchange_rate import ExchangeRateAPIError, ExchangeRateAPIErrorType
+from app.cli import app
+from app.commands import update_latest_exchange_rates
+from app.conf import Settings
+from app.db.handlers import ExchangeRatesHandler
+from app.db.models import ExchangeRates
+from tests.fixtures.mock_api_clients import MockExchangeRateAPIClient
+from tests.types import ModelFactory
+
+
+@time_machine.travel("2025-03-26T10:00:00Z", tick=False)
+async def test_successful_fetch_exchange_rates_when_missing(
+    test_settings: Settings,
+    db_session: AsyncSession,
+    mock_exchange_rate_api_client: MockExchangeRateAPIClient,
+    time_machine: time_machine.TimeMachineFixture,
+    caplog: pytest.LogCaptureFixture,
+):
+    exchange_rates_handler = ExchangeRatesHandler(db_session)
+
+    existing_exchange_rates = await exchange_rates_handler.query_db()
+    assert len(existing_exchange_rates) == 0
+    mock_exchange_rate_api_client.mock_get_latest_rates(
+        base_currency="USD",
+        exchange_rates={
+            "USD": 1.0,
+            "EUR": 0.9252,
+            "GBP": 0.7737,
+        },
+    )
+
+    with caplog.at_level(logging.INFO, logger="app.commands.update_latest_exchange_rates"):
+        await update_latest_exchange_rates.main(db_session.bind, test_settings)  # type: ignore
+
+    assert caplog.messages == [
+        "Exchange rates are not stored in the database or are no longer valid",
+        "Fetching latest exchange rates against USD",
+        "Successfully fetched exchange rates",
+        "Storing latest exchange rates into the database",
+        "Completed storing exchange rates",
+    ]
+
+    new_exchange_rates = await exchange_rates_handler.query_db()
+    assert len(new_exchange_rates) == 1
+
+    exchange_rates = new_exchange_rates[0]
+
+    assert exchange_rates.api_response["base_code"] == "USD"
+    assert exchange_rates.api_response["conversion_rates"] == {
+        "USD": 1.0,
+        "EUR": 0.9252,
+        "GBP": 0.7737,
+    }
+
+    assert exchange_rates.base_currency == exchange_rates.api_response["base_code"]
+    assert exchange_rates.last_update == datetime.fromtimestamp(
+        exchange_rates.api_response["time_last_update_unix"], UTC
+    )
+    assert exchange_rates.next_update == datetime.fromtimestamp(
+        exchange_rates.api_response["time_next_update_unix"], UTC
+    )
+
+
+@time_machine.travel("2025-03-26T10:00:00Z", tick=False)
+async def test_dont_fetch_exchange_rates_when_recent_are_present_in_db(
+    test_settings: Settings,
+    db_session: AsyncSession,
+    mock_exchange_rate_api_client: MockExchangeRateAPIClient,
+    exchange_rates_factory: ModelFactory[ExchangeRates],
+    caplog: pytest.LogCaptureFixture,
+):
+    await exchange_rates_factory()
+
+    with caplog.at_level(logging.INFO, logger="app.commands.update_latest_exchange_rates"):
+        await update_latest_exchange_rates.main(db_session.bind, test_settings)  # type: ignore
+
+    assert caplog.messages == [
+        "Exchange rates are already stored in the database and are still valid",
+        "Skipping fetching exchange rates",
+    ]
+
+    mock_exchange_rate_api_client.assert_no_api_calls()
+
+
+@time_machine.travel("2025-03-26T10:00:00Z", tick=False)
+async def test_fetch_exchange_rates_when_outdated_are_present_in_db(
+    test_settings: Settings,
+    db_session: AsyncSession,
+    mock_exchange_rate_api_client: MockExchangeRateAPIClient,
+    exchange_rates_factory: ModelFactory[ExchangeRates],
+    caplog: pytest.LogCaptureFixture,
+):
+    exchange_rates_handler = ExchangeRatesHandler(db_session)
+
+    await exchange_rates_factory(
+        last_update=datetime.now(UTC) - timedelta(days=1),
+        next_update=datetime.now(UTC) - timedelta(minutes=1),
+    )
+    mock_exchange_rate_api_client.mock_get_latest_rates(
+        base_currency="USD",
+        exchange_rates={  # A happy day for Europe :)
+            "USD": 1.0,
+            "EUR": 0.200,
+            "GBP": 0.100,
+        },
+    )
+
+    with caplog.at_level(logging.INFO, logger="app.commands.update_latest_exchange_rates"):
+        await update_latest_exchange_rates.main(db_session.bind, test_settings)  # type: ignore
+
+    assert caplog.messages == [
+        "Exchange rates are not stored in the database or are no longer valid",
+        "Fetching latest exchange rates against USD",
+        "Successfully fetched exchange rates",
+        "Storing latest exchange rates into the database",
+        "Completed storing exchange rates",
+    ]
+
+    new_exchange_rates = await exchange_rates_handler.query_db()
+    assert len(new_exchange_rates) == 2
+
+    latest_exchange_rates = await exchange_rates_handler.fetch_latest_valid()
+
+    assert latest_exchange_rates is not None
+    assert latest_exchange_rates.api_response["conversion_rates"] == {
+        "USD": 1.0,
+        "EUR": 0.200,
+        "GBP": 0.100,
+    }
+
+
+@time_machine.travel("2025-03-26T10:00:00Z", tick=False)
+async def test_exchange_rate_api_rerturns_unrecoverable_error(
+    test_settings: Settings,
+    db_session: AsyncSession,
+    mock_exchange_rate_api_client: MockExchangeRateAPIClient,
+    caplog: pytest.LogCaptureFixture,
+):
+    exchange_rates_handler = ExchangeRatesHandler(db_session)
+
+    mock_exchange_rate_api_client.mock_get_latest_rates(
+        error_code=ExchangeRateAPIErrorType.INACTIVE_ACCOUNT
+    )
+
+    with caplog.at_level(logging.INFO, logger="app.commands.update_latest_exchange_rates"):
+        with pytest.raises(ExchangeRateAPIError) as exc_info:
+            await update_latest_exchange_rates.main(db_session.bind, test_settings)  # type: ignore
+
+    assert exc_info.value.error_type == ExchangeRateAPIErrorType.INACTIVE_ACCOUNT
+
+    assert caplog.messages == [
+        "Exchange rates are not stored in the database or are no longer valid",
+        "Fetching latest exchange rates against USD",
+        "Failed to fetch exchange rates due to an exception",
+    ]
+
+    assert caplog.records[-1].exc_info is not None
+
+    assert not await exchange_rates_handler.query_db()
+
+
+@time_machine.travel("2025-03-26T10:00:00Z", tick=False)
+async def test_exchange_rate_api_timeouts_then_succeeds(
+    test_settings: Settings,
+    db_session: AsyncSession,
+    mock_exchange_rate_api_client: MockExchangeRateAPIClient,
+    caplog: pytest.LogCaptureFixture,
+):
+    exchange_rates_handler = ExchangeRatesHandler(db_session)
+
+    mock_exchange_rate_api_client.simulate_read_timeout()
+    mock_exchange_rate_api_client.mock_get_latest_rates(
+        base_currency="USD",
+        exchange_rates={
+            "USD": 1.0,
+            "EUR": 0.9252,
+            "GBP": 0.7737,
+        },
+    )
+
+    with caplog.at_level(logging.INFO, logger="app.commands.update_latest_exchange_rates"):
+        await update_latest_exchange_rates.main(db_session.bind, test_settings)  # type: ignore
+
+    latest_exchange_rates = await exchange_rates_handler.fetch_latest_valid()
+
+    assert latest_exchange_rates is not None
+    assert latest_exchange_rates.api_response["conversion_rates"] == {
+        "USD": 1.0,
+        "EUR": 0.9252,
+        "GBP": 0.7737,
+    }
+
+    assert caplog.messages == [
+        "Exchange rates are not stored in the database or are no longer valid",
+        "Fetching latest exchange rates against USD",
+        "stamina.retry_scheduled",
+        "Successfully fetched exchange rates",
+        "Storing latest exchange rates into the database",
+        "Completed storing exchange rates",
+    ]
+
+    stamina_log = caplog.records[2]
+    stamina_log_extra = stamina_log.__dict__
+
+    assert stamina_log_extra["stamina.caused_by"] == "ReadTimeout('Unable to read within timeout')"
+
+
+def test_cli_command(
+    db_engine: AsyncEngine,
+    mocker: MockerFixture,
+    test_settings: Settings,
+    mock_exchange_rate_api_client: MockExchangeRateAPIClient,
+):
+    mocker.patch("app.cli.get_settings", return_value=test_settings)
+    mock_command_coro = mocker.MagicMock()
+    mock_command = mocker.MagicMock(return_value=mock_command_coro)
+
+    mocker.patch("app.commands.update_latest_exchange_rates.main", mock_command)
+    mocker.patch(
+        "app.commands.update_latest_exchange_rates.get_db_engine",
+        return_value=db_engine,
+    )
+    mock_run = mocker.patch("app.commands.update_latest_exchange_rates.asyncio.run")
+    runner = CliRunner()
+
+    result = runner.invoke(app, ["update-latest-exchange-rates"])
+    assert result.exit_code == 0
+    mock_run.assert_called_once_with(mock_command_coro)
+
+    mock_command.assert_called_once_with(db_engine, test_settings)
+
+    mock_exchange_rate_api_client.assert_no_api_calls()
diff --git a/tests/conftest.py b/tests/conftest.py
index 636e1515..16d4c118 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,17 +3,15 @@
 from collections.abc import AsyncGenerator, Callable
 from datetime import UTC, datetime, timedelta
 from decimal import Decimal
-from typing import Any
 
 import jwt
 import pytest
+import stamina
 from asgi_lifespan import LifespanManager
 from faker import Faker
-from fastapi import FastAPI, status
+from fastapi import FastAPI
 from httpx import ASGITransport, AsyncClient
-from pydantic.v1.utils import deep_update
 from pytest_asyncio import is_async_test
-from pytest_httpx import HTTPXMock
 from sqlalchemy.ext.asyncio import (
     AsyncEngine,
     AsyncSession,
@@ -31,6 +29,7 @@
     ChargesFile,
     DatasourceExpense,
     Entitlement,
+    ExchangeRates,
     Organization,
     System,
     User,
@@ -49,6 +48,10 @@
 from tests.db.models import ModelForTests, ParentModelForTests  # noqa: F401
 from tests.types import ModelFactory
 
+pytest_plugins = [
+    "tests.fixtures.mock_api_clients",
+]
+
 
 def pytest_collection_modifyitems(items):
     pytest_asyncio_tests = (item for item in items if is_async_test(item))
@@ -70,6 +73,9 @@ def test_settings() -> Settings:
     settings.api_modifier_jwt_secret = "test_jwt_secret"
     settings.auth_access_jwt_secret = "auth_access_jwt_secret"
     settings.auth_refresh_jwt_secret = "auth_refresh_jwt_secret"
+    settings.exchange_rate_api_base_url = "https://v6.exchangerate-api.com/v6"
+    settings.exchange_rate_api_token = "my_exchange_rate_api_token"
+    settings.cli_rich_logging = False
     return settings
 
 
@@ -366,6 +372,7 @@ async def _charges_file(
         status: str | None = None,
         document_date: str | None = None,
     ):
+        owner = owner or gcp_account
         charges_file = ChargesFile(
             id=faker.uuid4(),
             document_date=datetime.strptime(document_date, format("%Y-%m-%d")).date()
@@ -374,7 +381,7 @@ async def _charges_file(
             currency=currency or "USD",
             amount=amount
             or Decimal(f"{faker.pydecimal(left_digits=14, right_digits=4, positive=True)}"),
-            owner=owner or gcp_account,
+            owner=owner,
             owner_id=owner.id or gcp_account.id,
             status=status or ChargesFileStatus.DRAFT,
         )
@@ -420,6 +427,48 @@ async def _datasource_expense(
     return _datasource_expense
 
 
+@pytest.fixture
+def exchange_rates_factory(db_session: AsyncSession) -> ModelFactory[ExchangeRates]:
+    async def _exchange_rates(
+        exchange_rates: dict[str, float] | None = None,
+        base_currency: str = "USD",
+        last_update: datetime | None = None,
+        next_update: datetime | None = None,
+    ):
+        if exchange_rates is None:
+            exchange_rates = {
+                "USD": 1.0,
+                "EUR": 0.9252,
+                "GBP": 0.7737,
+            }
+
+        if last_update is None:
+            last_update = datetime.now(UTC)
+
+        if next_update is None:
+            next_update = last_update + timedelta(days=1)
+
+        if last_update > next_update:
+            raise ValueError("Last update time must be before next update time")
+
+        exchange_rates_model = ExchangeRates(
+            api_response={
+                "result": "success",
+                "time_last_update_unix": int(last_update.timestamp()),
+                "time_next_update_unix": int(next_update.timestamp()),
+                "base_code": base_currency,
+                "conversion_rates": exchange_rates,
+            },
+        )
+
+        db_session.add(exchange_rates_model)
+        await db_session.commit()
+        await db_session.refresh(exchange_rates_model)
+        return exchange_rates_model
+
+    return _exchange_rates
+
+
 @pytest.fixture
 async def aws_account(account_factory: ModelFactory[Account]) -> Account:
     return await account_factory(name="AWS", type=AccountType.AFFILIATE)
@@ -534,65 +583,10 @@ async def apple_inc_organization(organization_factory: ModelFactory[Organization
     )
 
 
-class MockOptscaleClient:
-    def __init__(self, test_settings: Settings, httpx_mock: HTTPXMock):
-        self.test_settings = test_settings
-        self.httpx_mock = httpx_mock
-
-    def add_mock_response(self, method: str, url: str, **kwargs: Any) -> None:
-        self.httpx_mock.add_response(
-            method=method,
-            url=f"{self.test_settings.optscale_rest_api_base_url}/{url.removeprefix('/')}",
-            match_headers={"Secret": self.test_settings.optscale_cluster_secret},
-            **kwargs,
-        )
-
-    def mock_fetch_datasources_for_organization(
-        self,
-        organization: Organization,
-        cloud_account_configs: list[dict[str, Any]] | None = None,
-        status_code: int = status.HTTP_200_OK,
-    ):
-        if organization.linked_organization_id is None:
-            raise ValueError("Organization has no linked organization ID")
-
-        def cloud_account_details_factory(config: dict[str, Any]) -> dict[str, Any]:
-            return deep_update(
-                {
-                    "id": str(uuid.uuid4()),
-                    "deleted_at": 0,
-                    "created_at": 1729683941,
-                    "name": "CPA (Development and Test)",
-                    "type": "azure_cnr",
-                    "organization_id": organization.linked_organization_id,
-                    "account_id": str(uuid.uuid4()),
-                    "details": {
-                        "cost": 123.45,
-                        "forecast": 1099.0,
-                        "tracked": 2,
-                        "last_month_cost": 987.65,
-                    },
-                },
-                config,
-            )
-
-        json = None
-
-        if cloud_account_configs is not None:
-            json = {
-                "cloud_accounts": [
-                    cloud_account_details_factory(config) for config in cloud_account_configs
-                ]
-            }
-
-        self.add_mock_response(
-            "GET",
-            f"organizations/{organization.linked_organization_id}/cloud_accounts?details=true",
-            json=json,
-            status_code=status_code,
-        )
-
-
-@pytest.fixture
-def mock_optscale_client(test_settings: Settings, httpx_mock: HTTPXMock) -> MockOptscaleClient:
-    return MockOptscaleClient(test_settings, httpx_mock)
+@pytest.fixture(autouse=True, scope="session")
+def stamina_testing_mode():
+    stamina.set_testing(True, attempts=2)  # no backoff, maximum 2 attempts
+    try:
+        yield
+    finally:
+        stamina.set_testing(False)
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/fixtures/mock_api_clients.py b/tests/fixtures/mock_api_clients.py
new file mode 100644
index 00000000..e2ef68ac
--- /dev/null
+++ b/tests/fixtures/mock_api_clients.py
@@ -0,0 +1,177 @@
+import textwrap
+import uuid
+from datetime import UTC, datetime, timedelta
+from typing import Any, Protocol, TypeVar
+
+import httpx
+import pytest
+from fastapi import status
+from pydantic.v1.utils import deep_update
+from pytest_httpx import HTTPXMock
+
+from app.api_clients.base import BaseAPIClient
+from app.api_clients.exchange_rate import ExchangeRateAPIClient, ExchangeRateAPIErrorType
+from app.api_clients.optscale import OptscaleClient
+from app.conf import Settings
+from app.db.models import Organization
+
+C = TypeVar("C", bound=BaseAPIClient)
+
+
+class BaseMockAPIClient(Protocol[C]):
+    real_client: C
+    httpx_mock: HTTPXMock
+
+    def __init__(self, test_settings: Settings, httpx_mock: HTTPXMock):
+        real_client_cls = self._get_real_client_cls()
+
+        self.real_client = real_client_cls(test_settings)
+        self.httpx_mock = httpx_mock
+
+    @classmethod
+    def _get_real_client_cls(cls) -> type[C]:
+        geneneric_args = next(
+            base_cls.__args__
+            for base_cls in cls.__orig_bases__  # type: ignore[attr-defined]
+            if base_cls.__origin__ is BaseMockAPIClient
+        )
+
+        if not geneneric_args:
+            raise TypeError(f"Could not find generic arguments for {cls.__name__}")
+
+        if not len(geneneric_args) == 1:
+            raise TypeError(
+                f"Expected exactly one generic argument for {cls.__name__}, "
+                f"got {len(geneneric_args)}"
+            )
+
+        api_client_cls = geneneric_args[0]
+
+        if not issubclass(api_client_cls, BaseAPIClient):
+            raise TypeError("Generic argument must be a subclass of BaseAPIClient")
+
+        return api_client_cls
+
+    def commnon_matchers(self) -> dict[str, Any]:
+        return {}
+
+    def add_mock_response(self, method: str, url: str, **kwargs: Any) -> None:
+        self.httpx_mock.add_response(
+            method=method,
+            url=f"{self.real_client.base_url}/{url.removeprefix('/')}",
+            **self.commnon_matchers(),
+            **kwargs,
+        )
+
+    def simulate_read_timeout(self) -> None:
+        self.httpx_mock.add_exception(httpx.ReadTimeout("Unable to read within timeout"))
+
+    def assert_no_api_calls(self) -> None:
+        if api_requests := self.httpx_mock.get_requests():
+            api_calls_str = textwrap.indent("\n".join(map(str, api_requests)), "  ")
+
+            raise AssertionError(
+                f"Expected no API calls, got {len(api_requests)}:\n{api_calls_str}"
+            )
+
+
+class MockOptscaleClient(BaseMockAPIClient[OptscaleClient]):
+    def common_matchers(self):
+        return {"match_headers": {"Secret": self.real_client.settings.optscale_cluster_secret}}
+
+    def mock_fetch_datasources_for_organization(
+        self,
+        organization: Organization,
+        cloud_account_configs: list[dict[str, Any]] | None = None,
+        status_code: int = status.HTTP_200_OK,
+    ):
+        if organization.linked_organization_id is None:
+            raise ValueError("Organization has no linked organization ID")
+
+        def cloud_account_details_factory(config: dict[str, Any]) -> dict[str, Any]:
+            return deep_update(
+                {
+                    "id": str(uuid.uuid4()),
+                    "deleted_at": 0,
+                    "created_at": 1729683941,
+                    "name": "CPA (Development and Test)",
+                    "type": "azure_cnr",
+                    "organization_id": organization.linked_organization_id,
+                    "account_id": str(uuid.uuid4()),
+                    "details": {
+                        "cost": 123.45,
+                        "forecast": 1099.0,
+                        "tracked": 2,
+                        "last_month_cost": 987.65,
+                    },
+                },
+                config,
+            )
+
+        json = None
+
+        if cloud_account_configs is not None:
+            json = {
+                "cloud_accounts": [
+                    cloud_account_details_factory(config) for config in cloud_account_configs
+                ]
+            }
+
+        self.add_mock_response(
+            "GET",
+            f"organizations/{organization.linked_organization_id}/cloud_accounts?details=true",
+            json=json,
+            status_code=status_code,
+        )
+
+
+@pytest.fixture
+def mock_optscale_client(test_settings: Settings, httpx_mock: HTTPXMock) -> MockOptscaleClient:
+    return MockOptscaleClient(test_settings, httpx_mock)
+
+
+class MockExchangeRateAPIClient(BaseMockAPIClient[ExchangeRateAPIClient]):
+    API_RESPONSE_DATETIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
+
+    def mock_get_latest_rates(
+        self,
+        exchange_rates: dict[str, float] | None = None,
+        base_currency: str = "USD",
+        last_update: datetime | None = None,
+        next_update: datetime | None = None,
+        error_code: ExchangeRateAPIErrorType | None = None,
+    ) -> None:
+        if last_update is None:
+            last_update = datetime.now(UTC)
+
+        if next_update is None:
+            next_update = last_update + timedelta(days=1)
+
+        if last_update > next_update:
+            raise ValueError("Last update time must be before next update time")
+
+        if error_code is None:
+            response = {
+                "result": "success",
+                "time_last_update_unix": int(last_update.timestamp()),
+                "time_last_update_utc": last_update.strftime(self.API_RESPONSE_DATETIME_FORMAT),
+                "time_next_update_unix": int(next_update.timestamp()),
+                "time_next_update_utc": next_update.strftime(self.API_RESPONSE_DATETIME_FORMAT),
+                "base_code": base_currency,
+                "conversion_rates": exchange_rates,
+            }
+        else:
+            response = {
+                "result": "error",
+                "error-type": error_code.value,
+            }
+
+        self.add_mock_response("GET", f"/latest/{base_currency}", json=response)
+
+
+@pytest.fixture
+def mock_exchange_rate_api_client(
+    test_settings: Settings,
+    httpx_mock: HTTPXMock,
+) -> MockExchangeRateAPIClient:
+    return MockExchangeRateAPIClient(test_settings, httpx_mock)
diff --git a/uv.lock b/uv.lock
index be8ef633..779cdfff 100644
--- a/uv.lock
+++ b/uv.lock
@@ -683,6 +683,7 @@ dependencies = [
     { name = "requela" },
     { name = "sqlalchemy", extra = ["asyncio"] },
     { name = "sqlalchemy-utils" },
+    { name = "stamina" },
     { name = "svcs" },
     { name = "typer" },
     { name = "uvicorn-worker" },
@@ -731,6 +732,7 @@ requires-dist = [
     { name = "requela", specifier = "==0.1.*" },
     { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.*" },
     { name = "sqlalchemy-utils", specifier = "==0.41.*" },
+    { name = "stamina", specifier = ">=25.1.0" },
     { name = "svcs", specifier = "==24.1.*" },
     { name = "typer", specifier = "==0.13.*" },
     { name = "uvicorn-worker", specifier = "==0.3.*" },
@@ -1370,6 +1372,18 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 },
 ]
 
+[[package]]
+name = "stamina"
+version = "25.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "tenacity" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fd/c4/d242d76ffc88aa1fd14214d3143b542857b32276db4a20f8d99669054a5e/stamina-25.1.0.tar.gz", hash = "sha256:ad674809796ae40512b3b6296cfade826efd63863ff2ca2f59f806342e91e94a", size = 561127 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/14/ba/d03f7ee711391af1d5f4dd7c44f8abdd06bce247028af2441ba8f6ff329b/stamina-25.1.0-py3-none-any.whl", hash = "sha256:c08291da540e6f4243c20f7ee98f0ed0ac9101d639803c481a029b56d7e9b45d", size = 17323 },
+]
+
 [[package]]
 name = "starlette"
 version = "0.46.1"
@@ -1406,6 +1420,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/55/75/795347abe56c4012fd511af35db80370f1461aa443546a83b32d65a19bbb/svcs-24.1.0-py3-none-any.whl", hash = "sha256:f050f4a6fcf163aa3a5d5e17b66bbf5efa3ed2164018aebecd70fc27794ecf6b", size = 18772 },
 ]
 
+[[package]]
+name = "tenacity"
+version = "9.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/94/91fccdb4b8110642462e653d5dcb27e7b674742ad68efd146367da7bdb10/tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b", size = 47421 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b6/cb/b86984bed139586d01532a587464b5805f12e397594f19f931c4c2fbfa61/tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539", size = 28169 },
+]
+
 [[package]]
 name = "time-machine"
 version = "2.16.0"


Reviews:
user: login: ffaraone
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T16:39:54Z
user: login: arturbalabanov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T17:23:11Z
user: login: antoniodimariano
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T08:35:31Z
user: login: antoniodimariano
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T08:36:41Z
user: login: ffaraone
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T08:38:09Z
user: login: antoniodimariano
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T11:22:06Z
user: login: arturbalabanov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T11:45:09Z
user: login: ffaraone
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-27T11:59:08Z

Review comments:
diff_hunk: @@ -305,6 +315,48 @@ class DatasourceExpense(Base, HumanReadablePKMixin, AuditableMixin):
     )
 
 
+class ExchangeRates(Base, HumanReadablePKMixin, AuditableMixin):
path: app/db/models.py
user: login: ffaraone
body: IMO it should inherit from TimestampMixin, who creates, updates this object is always the command under cron schedule, so the *_by will always be null.
created_at: 2025-03-26T16:33:13Z
author_association: COLLABORATOR
diff_hunk: @@ -305,6 +315,48 @@ class DatasourceExpense(Base, HumanReadablePKMixin, AuditableMixin):
     )
 
 
+class ExchangeRates(Base, HumanReadablePKMixin, AuditableMixin):
path: app/db/models.py
user: login: arturbalabanov
body: I suppose so, was wondering the same for `DatasourceCosts` too. I don't think it hurts to have these additional columns, but sure will change it
created_at: 2025-03-26T17:23:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,75 @@
+import enum
+
+import httpx
+import stamina
+
+from app.api_clients.base import APIClientError, BaseAPIClient
+
+
+class ExchangeRateAPIErrorType(str, enum.Enum):
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+
+    UNSUPPORTED_CODE = "unsupported-code"
+    MALFORMED_REQUEST = "malformed-request"
+    INVALID_KEY = "invalid-key"
+    INACTIVE_ACCOUNT = "inactive-account"
+    QUOTA_REACHED = "quota-reached"
+    UNKNOWN = "unknown"
+
+    @classmethod
+    def _missing_(cls, value):
+        return cls.UNKNOWN
+
+
+class ExchangeRateAPIError(APIClientError):
+    # Exchange Rate API can return responses with a "result" field that is not "success"
+    # while still being 200 OK responses
+    #
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+    response: httpx.Response
+    response_result: str
+    error_type: ExchangeRateAPIErrorType
+
+    def __init__(self, response: httpx.Response):
+        data = response.json()
path: app/api_clients/exchange_rate.py
user: login: antoniodimariano
body: I would wrap the `response.json()` in a try-except to handle invalid JSON. 

created_at: 2025-03-27T08:25:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,75 @@
+import enum
+
+import httpx
+import stamina
+
+from app.api_clients.base import APIClientError, BaseAPIClient
+
+
+class ExchangeRateAPIErrorType(str, enum.Enum):
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+
+    UNSUPPORTED_CODE = "unsupported-code"
+    MALFORMED_REQUEST = "malformed-request"
+    INVALID_KEY = "invalid-key"
+    INACTIVE_ACCOUNT = "inactive-account"
+    QUOTA_REACHED = "quota-reached"
+    UNKNOWN = "unknown"
+
+    @classmethod
+    def _missing_(cls, value):
+        return cls.UNKNOWN
+
+
+class ExchangeRateAPIError(APIClientError):
+    # Exchange Rate API can return responses with a "result" field that is not "success"
+    # while still being 200 OK responses
+    #
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+    response: httpx.Response
+    response_result: str
+    error_type: ExchangeRateAPIErrorType
+
+    def __init__(self, response: httpx.Response):
+        data = response.json()
+        self.response = response
+        self.response_result = data["result"]
+        self.error_type = ExchangeRateAPIErrorType(data.get("error-type", "unknown"))
+
+        super().__init__(f"response result is {self.response_result}, code: {self.error_type}")
+
+    def should_retry(self) -> bool:
+        return self.error_type == ExchangeRateAPIErrorType.UNKNOWN
+
+
+class ExchangeRateAPIClient(BaseAPIClient):
+    auth = None  # Implemented in base_url
+
+    @property
+    def base_url(self):
+        return f"{self.settings.exchange_rate_api_base_url}/{self.settings.exchange_rate_api_token}"
+
+    @staticmethod
+    def retry_condition(exc: Exception) -> bool:
+        # If the error is an HTTP status error, only retry on 5xx errors.
+        if isinstance(exc, httpx.HTTPStatusError):
+            return exc.response.status_code >= 500
+
+        # "successful" responses (i.e. status_code < 400) can still have errors
+        # that we may want to retry on if it makes sense
+        if isinstance(exc, ExchangeRateAPIError):
+            return exc.should_retry()
+
+        # Retry on all other httpx errors (e.g. timeouts)
+        return isinstance(exc, httpx.HTTPError)
+
+    @stamina.retry(on=retry_condition, attempts=3)
+    async def get_latest_rates(self, base_currency: str) -> httpx.Response:
+        response = await self.httpx_client.get(f"/latest/{base_currency}")
+        response.raise_for_status()
+        response_data = response.json()
path: app/api_clients/exchange_rate.py
user: login: antoniodimariano
body: same here, like 
```
try:
            response_data = response.json()
        except ValueError:
            raise ExchangeRateAPIError(response)
```

created_at: 2025-03-27T08:27:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,75 @@
+import enum
+
+import httpx
+import stamina
+
+from app.api_clients.base import APIClientError, BaseAPIClient
+
+
+class ExchangeRateAPIErrorType(str, enum.Enum):
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+
+    UNSUPPORTED_CODE = "unsupported-code"
+    MALFORMED_REQUEST = "malformed-request"
+    INVALID_KEY = "invalid-key"
+    INACTIVE_ACCOUNT = "inactive-account"
+    QUOTA_REACHED = "quota-reached"
+    UNKNOWN = "unknown"
+
+    @classmethod
+    def _missing_(cls, value):
+        return cls.UNKNOWN
+
+
+class ExchangeRateAPIError(APIClientError):
+    # Exchange Rate API can return responses with a "result" field that is not "success"
+    # while still being 200 OK responses
+    #
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+    response: httpx.Response
+    response_result: str
+    error_type: ExchangeRateAPIErrorType
+
+    def __init__(self, response: httpx.Response):
+        data = response.json()
+        self.response = response
+        self.response_result = data["result"]
+        self.error_type = ExchangeRateAPIErrorType(data.get("error-type", "unknown"))
+
+        super().__init__(f"response result is {self.response_result}, code: {self.error_type}")
+
+    def should_retry(self) -> bool:
+        return self.error_type == ExchangeRateAPIErrorType.UNKNOWN
+
+
+class ExchangeRateAPIClient(BaseAPIClient):
+    auth = None  # Implemented in base_url
+
+    @property
+    def base_url(self):
+        return f"{self.settings.exchange_rate_api_base_url}/{self.settings.exchange_rate_api_token}"
+
+    @staticmethod
+    def retry_condition(exc: Exception) -> bool:
+        # If the error is an HTTP status error, only retry on 5xx errors.
+        if isinstance(exc, httpx.HTTPStatusError):
+            return exc.response.status_code >= 500
+
+        # "successful" responses (i.e. status_code < 400) can still have errors
+        # that we may want to retry on if it makes sense
+        if isinstance(exc, ExchangeRateAPIError):
+            return exc.should_retry()
+
+        # Retry on all other httpx errors (e.g. timeouts)
+        return isinstance(exc, httpx.HTTPError)
+
+    @stamina.retry(on=retry_condition, attempts=3)
+    async def get_latest_rates(self, base_currency: str) -> httpx.Response:
+        response = await self.httpx_client.get(f"/latest/{base_currency}")
+        response.raise_for_status()
+        response_data = response.json()
+
+        if response_data["result"] != "success":
path: app/api_clients/exchange_rate.py
user: login: antoniodimariano
body: Is there here a potential KeyError, or `result` is always in the `response_data`?
created_at: 2025-03-27T08:33:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -305,6 +315,48 @@ class DatasourceExpense(Base, HumanReadablePKMixin, AuditableMixin):
     )
 
 
+class ExchangeRates(Base, HumanReadablePKMixin, AuditableMixin):
path: app/db/models.py
user: login: ffaraone
body: 👍 
created_at: 2025-03-27T08:38:09Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,83 @@
+import enum
+
+import httpx
+import stamina
+
+from app.api_clients.base import APIClientError, BaseAPIClient
+
+
+class ExchangeRateAPIErrorType(str, enum.Enum):
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+
+    UNSUPPORTED_CODE = "unsupported-code"
+    MALFORMED_REQUEST = "malformed-request"
+    INVALID_KEY = "invalid-key"
+    INACTIVE_ACCOUNT = "inactive-account"
+    QUOTA_REACHED = "quota-reached"
+    UNKNOWN = "unknown"
+
+    @classmethod
+    def _missing_(cls, value):
+        return cls.UNKNOWN
+
+
+class ExchangeRateAPIError(APIClientError):
+    # Exchange Rate API can return responses with a "result" field that is not "success"
+    # while still being 200 OK responses
+    #
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+    response: httpx.Response
+    response_result: str | None
+    error_type: ExchangeRateAPIErrorType
+
+    def __init__(self, response: httpx.Response):
+        self.response = response
+
+        try:
+            data = response.json()
+            self.response_result = data.get("result")
path: app/api_clients/exchange_rate.py
user: login: antoniodimariano
body: `response_result` can be `None` here
created_at: 2025-03-27T11:22:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,83 @@
+import enum
+
+import httpx
+import stamina
+
+from app.api_clients.base import APIClientError, BaseAPIClient
+
+
+class ExchangeRateAPIErrorType(str, enum.Enum):
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+
+    UNSUPPORTED_CODE = "unsupported-code"
+    MALFORMED_REQUEST = "malformed-request"
+    INVALID_KEY = "invalid-key"
+    INACTIVE_ACCOUNT = "inactive-account"
+    QUOTA_REACHED = "quota-reached"
+    UNKNOWN = "unknown"
+
+    @classmethod
+    def _missing_(cls, value):
+        return cls.UNKNOWN
+
+
+class ExchangeRateAPIError(APIClientError):
+    # Exchange Rate API can return responses with a "result" field that is not "success"
+    # while still being 200 OK responses
+    #
+    # ref: https://www.exchangerate-api.com/docs/standard-requests
+    response: httpx.Response
+    response_result: str | None
+    error_type: ExchangeRateAPIErrorType
+
+    def __init__(self, response: httpx.Response):
+        self.response = response
+
+        try:
+            data = response.json()
+            self.response_result = data.get("result")
path: app/api_clients/exchange_rate.py
user: login: arturbalabanov
body: yep, same if `ValueError` occurs, even changed the type annotation above, this is the desired behaviour
created_at: 2025-03-27T11:45:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-27T12:05:44Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=softwareone-platform_ffc-finops-operations&pullRequest=136) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=softwareone-platform_ffc-finops-operations&pullRequest=136&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=softwareone-platform_ffc-finops-operations&pullRequest=136&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=softwareone-platform_ffc-finops-operations&pullRequest=136&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [97.3% Coverage on New Code](https://sonarcloud.io/component_measures?id=softwareone-platform_ffc-finops-operations&pullRequest=136&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=softwareone-platform_ffc-finops-operations&pullRequest=136&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=softwareone-platform_ffc-finops-operations&pullRequest=136)


author_association: NONE