{"record":{"id":"d13219b8f3778a38","repo":"we-promise/sure","slug":"eodhd-daily-rate-limit-of-max-requests-per-day","errorCode":null,"errorMessage":"EODHD daily rate limit of #{max_requests_per_day} requests exhausted","messagePattern":"EODHD daily rate limit of #(.+?) requests exhausted","errorType":"exception","errorClass":"Provider::Eodhd::RateLimitError","httpStatus":null,"severity":"error","filePath":"app/models/provider/eodhd.rb","lineNumber":291,"sourceCode":"      elsif exchange_operating_mic.present?\n        \"#{symbol}.#{exchange_operating_mic}\"\n      else\n        \"#{symbol}.US\"\n      end\n    end\n\n    # Cache key for tracking daily API usage\n    def daily_cache_key\n      \"eodhd:daily:#{Date.current}\"\n    end\n\n    # Enforces the daily rate limit. Raises RateLimitError if the limit is exhausted.\n    # Uses atomic increment-then-check to avoid TOCTOU races between concurrent workers.\n    def enforce_daily_limit!\n      new_count = Rails.cache.increment(daily_cache_key, 1, expires_in: 24.hours).to_i\n\n      if new_count > max_requests_per_day\n        raise RateLimitError, \"EODHD daily rate limit of #{max_requests_per_day} requests exhausted\"\n      end\n    end\n\n    # throttle_request and min_request_interval provided by RateLimitable\n\n    def max_requests_per_day\n      ENV.fetch(\"EODHD_MAX_REQUESTS_PER_DAY\", MAX_REQUESTS_PER_DAY).to_i\n    end\n\n    def check_api_error!(parsed)\n      return unless parsed.is_a?(Hash) && parsed[\"error\"].present?\n\n      raise Error, \"API error: #{parsed[\"error\"]}\"\n    end\nend\n","sourceCodeStart":273,"sourceCodeEnd":307,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/eodhd.rb#L273-L307","documentation":"Raised as Provider::Eodhd::RateLimitError when the shared daily counter in Rails.cache ('eodhd:daily:<Date.current>') exceeds max_requests_per_day (default from MAX_REQUESTS_PER_DAY, overridable via EODHD_MAX_REQUESTS_PER_DAY). The counter is incremented atomically before each request (increment-then-check), so concurrent workers cannot undercount. Once the cap is hit, every further EODHD call that day raises immediately without touching the network.","triggerScenarios":"Any EODHD API call (search, fetch_security_prices, fetch_security_price) after the day's counter has already reached the cap — e.g. a large multi-security backfill issuing thousands of EOD requests, multiple Sidekiq workers syncing in parallel, or repeated failed runs each burning increments. The counter is cache-global, not per-account, so all EODHD usage shares the budget.","commonSituations":"Initial import of a portfolio with many securities, retries of a failing sync each consuming requests, a misconfigured cache store (e.g. memory_store per-process) that under- or over-counts, EODHD_MAX_REQUESTS_PER_DAY left at the free-tier default while the app runs paid-tier volume, and month-end valuation jobs that price hundreds of holdings on one day.","solutions":["Wait for the UTC day to roll over — the cache key is date-scoped ('eodhd:daily:<date>') and resets naturally","Raise the budget if your plan allows: set EODHD_MAX_REQUESTS_PER_DAY in the environment to your plan's real quota","Check for runaway callers: DebugLogEntry / logs showing unexpected EODHD request volume (each failed retry still increments)","Ensure Rails.cache is a shared store (Redis/Memcached) in multi-process deployments so all workers see one counter","Batch or schedule price backfills across days, and cache per-symbol/day results so repeated valuations do not re-query EODHD"],"exampleFix":"# before: every valuation day re-fetches all prices, exhausting the daily cap\nholdings.each { |h| provider.fetch_security_price(symbol: h.symbol, date: Date.current) }\n\n# after: cache results per symbol/date so the daily budget is spent once\nholdings.each do |h|\n  key = \"price:#{h.symbol}:#{Date.current}\"\n  price = Rails.cache.fetch(key, expires_in: 12.hours) do\n    provider.fetch_security_price(symbol: h.symbol, date: Date.current)\n  end\nend","handlingStrategy":"retry","validationCode":"# Check remaining budget before starting an expensive backfill\nREMAINING = provider.max_requests_per_day - Rails.cache.read(\"eodhd:daily:#{Date.current}\").to_i\nBUDGET_NEEDED = symbols.size # one request per symbol window\n\nif REMAINING < BUDGET_NEEDED\n  ScheduleBackfillJob.set(wait_until: Date.tomorrow.midnight + 5.minutes).perform_later(...)\n  return\nend","typeGuard":null,"tryCatchPattern":"begin\n  provider.fetch_security_prices(...)\nrescue Provider::Eodhd::RateLimitError\n  # daily cap is calendar-scoped: reschedule for after UTC midnight, do not retry now\n  SyncJob.set(wait_until: Date.tomorrow.midnight + 5.minutes).perform_later(job.id)\nend","preventionTips":["Set EODHD_MAX_REQUESTS_PER_DAY to your plan's true quota, with headroom","Use a shared cache store (Redis) in multi-process deployments so the counter is global","Cache price results per symbol/date to avoid re-spending budget on repeated valuations","Pre-check remaining budget before launching large backfills and defer them to the next day"],"tags":["eodhd","rate-limit","quota","cache","securities"],"backgroundTag":"rate-limit-exceeded","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}