{"record":{"id":"764444b33174391c","repo":"we-promise/sure","slug":"tiingo-unique-symbol-limit-reached-max-symbols","errorCode":null,"errorMessage":"Tiingo unique symbol limit reached (#{MAX_SYMBOLS_PER_MONTH} per month)","messagePattern":"Tiingo unique symbol limit reached \\(#(.+?) per month\\)","errorType":"exception","errorClass":"Provider::Tiingo::RateLimitError","httpStatus":null,"severity":"error","filePath":"app/models/provider/tiingo.rb","lineNumber":266,"sourceCode":"\n    # Tracks unique symbols queried per month to stay within Tiingo's 500 symbols/month limit.\n    # Uses atomic set-if-absent (Redis SETNX) to eliminate the read-then-write race\n    # where two concurrent workers could both see the symbol as untracked and both\n    # increment the counter.\n    def track_symbol(symbol)\n      symbol_key = \"tiingo:symbol:#{Date.current.strftime('%Y-%m')}:#{symbol.upcase}\"\n      count_key  = \"tiingo:symbol_count:#{Date.current.strftime('%Y-%m')}\"\n\n      # Atomic write-if-absent: returns false when the key already exists (Redis SETNX).\n      # Only the first worker to claim this symbol will proceed to increment the counter.\n      return unless Rails.cache.write(symbol_key, true, expires_in: 35.days, unless_exist: true)\n\n      new_count = Rails.cache.increment(count_key, 1, expires_in: 35.days).to_i\n\n      if new_count >= MAX_SYMBOLS_PER_MONTH\n        Rails.cache.decrement(count_key, 1)\n        Rails.cache.delete(symbol_key)\n        raise RateLimitError, \"Tiingo unique symbol limit reached (#{MAX_SYMBOLS_PER_MONTH} per month)\"\n      end\n    end\n\n    # min_request_interval provided by RateLimitable\n\n    def max_requests_per_hour\n      ENV.fetch(\"TIINGO_MAX_REQUESTS_PER_HOUR\", MAX_REQUESTS_PER_HOUR).to_i\n    end\n\n    # Fetches the price currency for a symbol via the search endpoint.\n    # Only called as a fallback when the cache (populated by search_securities)\n    # doesn't have the currency. Raises on failure to avoid silently mislabeling\n    # non-USD instruments as USD.\n    def fetch_currency_for_symbol(symbol)\n      throttle_request\n\n      response = client.get(\"#{base_url}/tiingo/utilities/search\") do |req|\n        req.params[\"query\"] = symbol","sourceCodeStart":248,"sourceCodeEnd":284,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/tiingo.rb#L248-L284","documentation":"Tiingo's free tier allows 500 unique symbols per calendar month. track_symbol atomically claims a new symbol with a SETNX-style cache write ('tiingo:symbol:<YYYY-MM>:<SYMBOL>', 35-day TTL); only the claimer increments the month counter ('tiingo:symbol_count:<YYYY-MM>'). If the new count would reach MAX_SYMBOLS_PER_MONTH (500), the code rolls back (decrement counter, delete the symbol key) and raises RateLimitError -- so this symbol does not consume budget and remains unclaimed. Like the hourly gate, this is enforced client-side before the request.","triggerScenarios":"The first fetch_security_prices call for a 501st distinct symbol in a calendar month (search-only symbols don't count; a symbol counts when prices are fetched). Repeats of already-tracked symbols never trigger it. Rollback means the same symbol can be retried next month or after an upgrade with no residue.","commonSituations":"Portfolio importer adding many tickers at once late in the month; users on self-hosted instances sharing one Tiingo key exhausting the shared budget; watchlist scans touching symbols never actually owned.","solutions":["Wait for the monthly rollover -- the keys are month-scoped (Date.current '%Y-%m') and expire after 35 days","Upgrade the Tiingo plan or use a different provider for the extra symbols","Trim what you fetch: only price securities actually held; check provider.usage (reads tiingo:symbol_count) before importing a batch"],"exampleFix":"# before\nsecurity.prices.each { |sym| provider.fetch_security_prices(symbol: sym, start_date: from, end_date: to) }\n\n# after\nreturn if provider.usage.utilization >= 100\nsecurity.prices.each { |sym| provider.fetch_security_prices(symbol: sym, start_date: from, end_date: to) }","handlingStrategy":"validation","validationCode":"# Read the monthly budget before introducing a new symbol\nused = Rails.cache.read(\"tiingo:symbol_count:#{Date.current.strftime('%Y-%m')}\").to_i\nreturn if used >= 500","typeGuard":null,"tryCatchPattern":"begin\n  provider.fetch_security_prices(symbol:, start_date:, end_date:)\nrescue Provider::Tiingo::RateLimitError => e\n  # rollback already ran; symbol is unclaimed, safe to retry next month or on another provider\n  SecurityPriceFetch.set(wait: until_next_month).perform_later(...)\nend","preventionTips":["Only fetch prices for securities actually held, not watchlists or search previews","Surface provider.usage (used/500) in admin UI to see budget burn before it trips"],"tags":["tiingo","rate-limit","quota","monthly-symbol-limit","free-tier"],"backgroundTag":"api-quota-exceeded","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}