DhanHQ 2.1.8 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/.rubocop_todo.yml +143 -118
  4. data/CHANGELOG.md +177 -0
  5. data/CODE_REVIEW_ISSUES.md +397 -0
  6. data/FIXES_APPLIED.md +373 -0
  7. data/GUIDE.md +41 -0
  8. data/README.md +55 -0
  9. data/RELEASING.md +60 -0
  10. data/REVIEW_SUMMARY.md +120 -0
  11. data/VERSION_UPDATE.md +82 -0
  12. data/core +0 -0
  13. data/docs/AUTHENTICATION.md +63 -0
  14. data/docs/DATA_API_PARAMETERS.md +278 -0
  15. data/docs/PR_2.2.0.md +48 -0
  16. data/docs/RELEASE_GUIDE.md +492 -0
  17. data/docs/TESTING_GUIDE.md +1514 -0
  18. data/docs/live_order_updates.md +25 -1
  19. data/docs/rails_integration.md +29 -0
  20. data/docs/websocket_integration.md +22 -0
  21. data/lib/DhanHQ/client.rb +65 -9
  22. data/lib/DhanHQ/configuration.rb +26 -0
  23. data/lib/DhanHQ/constants.rb +1 -1
  24. data/lib/DhanHQ/contracts/expired_options_data_contract.rb +6 -6
  25. data/lib/DhanHQ/contracts/place_order_contract.rb +51 -0
  26. data/lib/DhanHQ/core/base_model.rb +26 -11
  27. data/lib/DhanHQ/errors.rb +4 -0
  28. data/lib/DhanHQ/helpers/request_helper.rb +17 -2
  29. data/lib/DhanHQ/helpers/response_helper.rb +34 -13
  30. data/lib/DhanHQ/models/edis.rb +150 -14
  31. data/lib/DhanHQ/models/expired_options_data.rb +307 -88
  32. data/lib/DhanHQ/models/forever_order.rb +261 -22
  33. data/lib/DhanHQ/models/funds.rb +76 -10
  34. data/lib/DhanHQ/models/historical_data.rb +148 -31
  35. data/lib/DhanHQ/models/holding.rb +82 -6
  36. data/lib/DhanHQ/models/instrument_helpers.rb +4 -4
  37. data/lib/DhanHQ/models/kill_switch.rb +113 -11
  38. data/lib/DhanHQ/models/ledger_entry.rb +101 -13
  39. data/lib/DhanHQ/models/margin.rb +133 -8
  40. data/lib/DhanHQ/models/market_feed.rb +181 -17
  41. data/lib/DhanHQ/models/option_chain.rb +184 -12
  42. data/lib/DhanHQ/models/order.rb +418 -36
  43. data/lib/DhanHQ/models/order_update.rb +0 -4
  44. data/lib/DhanHQ/models/position.rb +161 -10
  45. data/lib/DhanHQ/models/profile.rb +103 -7
  46. data/lib/DhanHQ/models/super_order.rb +275 -15
  47. data/lib/DhanHQ/models/trade.rb +279 -26
  48. data/lib/DhanHQ/rate_limiter.rb +40 -6
  49. data/lib/DhanHQ/resources/expired_options_data.rb +1 -1
  50. data/lib/DhanHQ/version.rb +1 -1
  51. data/lib/DhanHQ/ws/client.rb +11 -5
  52. data/lib/DhanHQ/ws/connection.rb +16 -2
  53. data/lib/DhanHQ/ws/market_depth/client.rb +2 -1
  54. data/lib/DhanHQ/ws/market_depth.rb +12 -12
  55. data/lib/DhanHQ/ws/orders/client.rb +78 -12
  56. data/lib/DhanHQ/ws/orders/connection.rb +2 -1
  57. data/lib/DhanHQ/ws/orders.rb +2 -1
  58. metadata +18 -5
  59. data/lib/DhanHQ/contracts/modify_order_contract_copy.rb +0 -100
@@ -18,7 +18,7 @@ The DhanHQ Ruby client provides comprehensive real-time order update functionali
18
18
  ```ruby
19
19
  require 'dhan_hq'
20
20
 
21
- # Configure credentials
21
+ # Configure credentials (or use config.access_token_provider for dynamic token; see docs/AUTHENTICATION.md)
22
22
  DhanHQ.configure do |config|
23
23
  config.client_id = "your_client_id"
24
24
  config.access_token = "your_access_token"
@@ -317,3 +317,27 @@ All operations are thread-safe using `Concurrent::Map` and `Concurrent::AtomicBo
317
317
  - Connection state management
318
318
 
319
319
  This ensures safe usage in multi-threaded applications.
320
+
321
+ ## Testing
322
+
323
+ For comprehensive testing examples and interactive console helpers, see the [Testing Guide](TESTING_GUIDE.md). The guide includes:
324
+
325
+ - **Order Update WebSocket Testing**: Complete examples for all order update features
326
+ - **Event Handler Testing**: Examples for all event types
327
+ - **Order State Management**: Testing order tracking and querying
328
+ - **Interactive Console Helpers**: Load `bin/test_helpers.rb` for quick test functions
329
+
330
+ **Quick start in console:**
331
+ ```ruby
332
+ # Start console
333
+ bin/console
334
+
335
+ # Load test helpers
336
+ load 'bin/test_helpers.rb'
337
+
338
+ # Test order WebSocket
339
+ test_order_websocket(10) # Test order updates for 10 seconds
340
+
341
+ # Monitor specific order
342
+ monitor_order("112111182045") # Monitor order by ID
343
+ ```
@@ -46,6 +46,11 @@ dhanhq:
46
46
  ws_user_type: "SELF" # optional (SELF or PARTNER)
47
47
  partner_id: "your-partner-id" # optional when ws_user_type: PARTNER
48
48
  partner_secret: "your-partner-secret" # optional when ws_user_type: PARTNER
49
+ connect_timeout: 10 # optional, connection timeout in seconds
50
+ read_timeout: 30 # optional, read timeout in seconds
51
+ write_timeout: 30 # optional, write timeout in seconds
52
+ ws_max_tracked_orders: 10000 # optional, max orders to track in WebSocket
53
+ ws_max_order_age: 604800 # optional, max order age in seconds (7 days)
49
54
  ```
50
55
 
51
56
  Create an initializer so your app boots with the correct configuration via
@@ -64,6 +69,11 @@ if (creds = Rails.application.credentials.dig(:dhanhq))
64
69
  ENV['DHAN_WS_USER_TYPE'] ||= creds[:ws_user_type]
65
70
  ENV['DHAN_PARTNER_ID'] ||= creds[:partner_id]
66
71
  ENV['DHAN_PARTNER_SECRET'] ||= creds[:partner_secret]
72
+ ENV['DHAN_CONNECT_TIMEOUT'] ||= creds[:connect_timeout]&.to_s
73
+ ENV['DHAN_READ_TIMEOUT'] ||= creds[:read_timeout]&.to_s
74
+ ENV['DHAN_WRITE_TIMEOUT'] ||= creds[:write_timeout]&.to_s
75
+ ENV['DHAN_WS_MAX_TRACKED_ORDERS'] ||= creds[:ws_max_tracked_orders]&.to_s
76
+ ENV['DHAN_WS_MAX_ORDER_AGE'] ||= creds[:ws_max_order_age]&.to_s
67
77
  end
68
78
 
69
79
  # fall back to traditional ENV variables when credentials are not defined
@@ -93,6 +103,25 @@ or enable partner streaming flows:
93
103
  Set the variables in ENV (or in credentials copied to ENV) **before** the
94
104
  initializer calls `DhanHQ.configure_with_env`.
95
105
 
106
+ **Dynamic access token (optional)**
107
+
108
+ For token rotation without restarting the app (e.g. token stored in DB or refreshed via OAuth), use `access_token_provider` so the token is resolved at request time:
109
+
110
+ ```ruby
111
+ # config/initializers/dhanhq.rb (alternative to static ACCESS_TOKEN)
112
+ DhanHQ.configure do |config|
113
+ config.client_id = ENV["DHAN_CLIENT_ID"] || Rails.application.credentials.dig(:dhanhq, :client_id)
114
+ config.access_token_provider = lambda do
115
+ record = DhanAccessToken.active # your model or service
116
+ raise "Dhan token expired or missing" unless record
117
+ record.access_token
118
+ end
119
+ config.on_token_expired = ->(error) { DhanAccessToken.refresh! } # optional: run before retry
120
+ end
121
+ ```
122
+
123
+ When the API returns 401 or token-expired (error code 807) and `access_token_provider` is set, the client retries the request **once** with a fresh token from the provider. `on_token_expired` is called before that retry so you can refresh your store if needed.
124
+
96
125
  ## 3. Build service objects for REST flows
97
126
 
98
127
  Wrap trading actions in plain-old Ruby objects so controllers and jobs stay thin:
@@ -32,6 +32,7 @@ DhanHQ.configure do |config|
32
32
  config.access_token = ENV["ACCESS_TOKEN"] || "your_access_token"
33
33
  config.ws_user_type = ENV["DHAN_WS_USER_TYPE"] || "SELF"
34
34
  end
35
+ # For dynamic token at request time (REST + WebSocket), use config.access_token_provider; see docs/AUTHENTICATION.md
35
36
  ```
36
37
 
37
38
  ### 2. Market Feed WebSocket (Recommended for Beginners)
@@ -894,6 +895,27 @@ puts "Market subscriptions: #{market_client.subscriptions}"
894
895
  puts "Depth subscriptions: #{depth_client.subscriptions}"
895
896
  ```
896
897
 
898
+ ## Testing
899
+
900
+ For comprehensive testing examples and interactive console helpers, see the [Testing Guide](TESTING_GUIDE.md). The guide includes:
901
+
902
+ - **WebSocket Testing**: Market feed, order updates, and market depth examples
903
+ - **Interactive Console Helpers**: Load `bin/test_helpers.rb` for quick test functions
904
+ - **Complete Examples**: Copy-paste examples for all WebSocket features
905
+
906
+ **Quick start in console:**
907
+ ```ruby
908
+ # Start console
909
+ bin/console
910
+
911
+ # Load test helpers
912
+ load 'bin/test_helpers.rb'
913
+
914
+ # Test WebSocket connections
915
+ test_websocket(:ticker, 5) # Test market feed for 5 seconds
916
+ test_order_websocket(5) # Test order updates for 5 seconds
917
+ ```
918
+
897
919
  ## Examples
898
920
 
899
921
  The gem includes comprehensive examples in the `examples/` directory:
data/lib/DhanHQ/client.rb CHANGED
@@ -36,38 +36,83 @@ module DhanHQ
36
36
  #
37
37
  # @param api_type [Symbol] Type of API (`:order_api`, `:data_api`, `:non_trading_api`)
38
38
  # @return [DhanHQ::Client] A new client instance.
39
+ # @raise [DhanHQ::Error] If configuration is invalid or rate limiter initialization fails
39
40
  def initialize(api_type:)
41
+ # Configure from ENV if CLIENT_ID is present (backward compatible behavior)
42
+ # Validation happens at request time in build_headers, not here
40
43
  DhanHQ.configure_with_env if ENV.fetch("CLIENT_ID", nil)
44
+
41
45
  # Use shared rate limiter instance per API type to ensure proper coordination
42
46
  @rate_limiter = RateLimiter.for(api_type)
43
47
 
44
- raise "RateLimiter initialization failed" unless @rate_limiter
48
+ raise DhanHQ::Error, "RateLimiter initialization failed" unless @rate_limiter
49
+
50
+ # Get timeout values from configuration or environment, with sensible defaults
51
+ connect_timeout = ENV.fetch("DHAN_CONNECT_TIMEOUT", 10).to_i
52
+ read_timeout = ENV.fetch("DHAN_READ_TIMEOUT", 30).to_i
53
+ write_timeout = ENV.fetch("DHAN_WRITE_TIMEOUT", 30).to_i
45
54
 
46
55
  @connection = Faraday.new(url: DhanHQ.configuration.base_url) do |conn|
47
56
  conn.request :json, parser_options: { symbolize_names: true }
48
57
  conn.response :json, content_type: /\bjson$/
49
58
  conn.response :logger if ENV["DHAN_DEBUG"] == "true"
59
+ conn.options.timeout = read_timeout
60
+ conn.options.open_timeout = connect_timeout
61
+ conn.options.write_timeout = write_timeout
50
62
  conn.adapter Faraday.default_adapter
51
63
  end
52
64
  end
53
65
 
54
- # Sends an HTTP request to the API.
66
+ # Sends an HTTP request to the API with automatic retry for transient errors.
55
67
  #
56
68
  # @param method [Symbol] The HTTP method (`:get`, `:post`, `:put`, `:delete`)
57
69
  # @param path [String] The API endpoint path.
58
70
  # @param payload [Hash] The request parameters or body.
71
+ # @param retries [Integer] Number of retries for transient errors (default: 3)
59
72
  # @return [HashWithIndifferentAccess, Array<HashWithIndifferentAccess>] Parsed JSON response.
60
73
  # @raise [DhanHQ::Error] If an HTTP error occurs.
61
- def request(method, path, payload)
74
+ def request(method, path, payload, retries: 3)
62
75
  @rate_limiter.throttle! # **Ensure we don't hit rate limit before calling API**
63
76
 
64
- response = connection.send(method) do |req|
65
- req.url path
66
- req.headers.merge!(build_headers(path))
67
- prepare_payload(req, payload, method)
68
- end
77
+ attempt = 0
78
+ auth_retry_done = false
79
+ begin
80
+ response = connection.send(method) do |req|
81
+ req.url path
82
+ req.headers.merge!(build_headers(path))
83
+ prepare_payload(req, payload, method)
84
+ end
69
85
 
70
- handle_response(response)
86
+ handle_response(response)
87
+ rescue DhanHQ::InvalidAuthenticationError, DhanHQ::InvalidTokenError,
88
+ DhanHQ::TokenExpiredError, DhanHQ::AuthenticationFailedError => e
89
+ config = DhanHQ.configuration
90
+ if !auth_retry_done && config&.access_token_provider
91
+ auth_retry_done = true
92
+ config.on_token_expired&.call(e)
93
+ DhanHQ.logger&.warn("[DhanHQ::Client] Auth failure (#{e.class}), retrying once with fresh token")
94
+ retry
95
+ end
96
+ raise
97
+ rescue DhanHQ::RateLimitError, DhanHQ::InternalServerError, DhanHQ::NetworkError => e
98
+ attempt += 1
99
+ if attempt <= retries
100
+ backoff_time = calculate_backoff(attempt)
101
+ DhanHQ.logger&.warn("[DhanHQ::Client] Transient error (#{e.class}), retrying in #{backoff_time}s (attempt #{attempt}/#{retries})")
102
+ sleep(backoff_time)
103
+ retry
104
+ end
105
+ raise
106
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
107
+ attempt += 1
108
+ if attempt <= retries
109
+ backoff_time = calculate_backoff(attempt)
110
+ DhanHQ.logger&.warn("[DhanHQ::Client] Network error (#{e.class}), retrying in #{backoff_time}s (attempt #{attempt}/#{retries})")
111
+ sleep(backoff_time)
112
+ retry
113
+ end
114
+ raise DhanHQ::NetworkError, "Request failed after #{retries} retries: #{e.message}"
115
+ end
71
116
  end
72
117
 
73
118
  # Convenience wrapper for issuing a GET request.
@@ -113,5 +158,16 @@ module DhanHQ
113
158
  def delete(path, params = {})
114
159
  request(:delete, path, params)
115
160
  end
161
+
162
+ private
163
+
164
+ # Calculates exponential backoff time
165
+ #
166
+ # @param attempt [Integer] Current attempt number (1-based)
167
+ # @return [Float] Backoff time in seconds
168
+ def calculate_backoff(attempt)
169
+ # Exponential backoff: 1s, 2s, 4s, 8s, etc., capped at 30s
170
+ [2**(attempt - 1), 30].min.to_f
171
+ end
116
172
  end
117
173
  end
@@ -20,6 +20,17 @@ module DhanHQ
20
20
  # @return [String, nil] The access token or `nil` if not set.
21
21
  attr_accessor :access_token
22
22
 
23
+ # Optional callable (Proc/lambda) that returns the access token at request time.
24
+ # When set, {#resolved_access_token} calls this instead of using {#access_token}.
25
+ # @return [Proc, nil]
26
+ attr_accessor :access_token_provider
27
+
28
+ # Optional callable invoked when the API returns 401/token-expired and the client
29
+ # is about to retry (when {#access_token_provider} is set). Use for logging or
30
+ # refreshing token in your store before the retry fetches a new token.
31
+ # @return [Proc, nil]
32
+ attr_accessor :on_token_expired
33
+
23
34
  # The base URL for API requests.
24
35
  # @return [String] The base URL for the DhanHQ API.
25
36
  attr_accessor :base_url
@@ -64,6 +75,21 @@ module DhanHQ
64
75
  # @return [String, nil]
65
76
  attr_accessor :partner_secret
66
77
 
78
+ # Returns the access token to use for this request.
79
+ # If {#access_token_provider} is set, calls it (no memoization; token per request).
80
+ # Otherwise returns {#access_token}.
81
+ # @return [String]
82
+ # @raise [DhanHQ::AuthenticationError] if provider returns nil/empty or no token is set.
83
+ def resolved_access_token
84
+ if access_token_provider
85
+ token = access_token_provider.call
86
+ raise DhanHQ::AuthenticationError, "access_token_provider returned nil or empty" if token.nil? || token.to_s.empty?
87
+ token.to_s
88
+ else
89
+ access_token
90
+ end
91
+ end
92
+
67
93
  # Initializes a new configuration instance with default values.
68
94
  #
69
95
  # @example
@@ -171,7 +171,7 @@ module DhanHQ
171
171
  "804" => DhanHQ::Error, # Too many instruments
172
172
  "805" => DhanHQ::RateLimitError, # Too many requests
173
173
  "806" => DhanHQ::DataError, # Data API not subscribed
174
- "807" => DhanHQ::InvalidTokenError, # Token expired
174
+ "807" => DhanHQ::TokenExpiredError, # Token expired
175
175
  "808" => DhanHQ::AuthenticationFailedError, # Auth failed
176
176
  "809" => DhanHQ::InvalidTokenError, # Invalid token
177
177
  "810" => DhanHQ::InvalidClientIDError, # Invalid Client ID
@@ -10,8 +10,8 @@ module DhanHQ
10
10
  class ExpiredOptionsDataContract < BaseContract
11
11
  params do
12
12
  required(:exchange_segment).filled(:string)
13
- required(:interval).filled(:integer)
14
- required(:security_id).filled(:string)
13
+ required(:interval).filled(:string)
14
+ required(:security_id).filled(:integer)
15
15
  required(:instrument).filled(:string)
16
16
  required(:expiry_flag).filled(:string)
17
17
  required(:expiry_code).filled(:integer)
@@ -28,7 +28,7 @@ module DhanHQ
28
28
  end
29
29
 
30
30
  rule(:interval) do
31
- valid_intervals = [1, 5, 15, 25, 60]
31
+ valid_intervals = %w[1 5 15 25 60]
32
32
  key.failure("must be one of: #{valid_intervals.join(", ")}") unless valid_intervals.include?(value)
33
33
  end
34
34
 
@@ -69,10 +69,10 @@ module DhanHQ
69
69
  from_date = Date.parse(values[:from_date])
70
70
  to_date = Date.parse(values[:to_date])
71
71
 
72
- key.failure("from_date must be before to_date") if from_date >= to_date
72
+ key.failure("from_date must be on or before to_date") if from_date > to_date
73
73
 
74
- # Check if date range is not too large (max 30 days)
75
- key.failure("date range cannot exceed 30 days") if (to_date - from_date).to_i > 30
74
+ # Check if date range is not too large (max 31 days; to_date is non-inclusive)
75
+ key.failure("date range cannot exceed 31 days") if (to_date - from_date).to_i > 31
76
76
 
77
77
  # Check if from_date is not too far in the past (max 5 years)
78
78
  five_years_ago = Date.today - (5 * 365)
@@ -93,6 +93,57 @@ module DhanHQ
93
93
  optional(:drv_strike_price).maybe(:float, gt?: 0)
94
94
  end
95
95
 
96
+ # Validate that float values are finite (not NaN or Infinity) and within reasonable bounds
97
+ rule(:price) do
98
+ if values[:price].is_a?(Float)
99
+ if values[:price].nan? || values[:price].infinite?
100
+ key(:price).failure("must be a finite number")
101
+ elsif values[:price] > 1_000_000_000
102
+ key(:price).failure("must be less than 1,000,000,000")
103
+ end
104
+ end
105
+ end
106
+
107
+ rule(:trigger_price) do
108
+ if values[:trigger_price].is_a?(Float)
109
+ if values[:trigger_price].nan? || values[:trigger_price].infinite?
110
+ key(:trigger_price).failure("must be a finite number")
111
+ elsif values[:trigger_price] > 1_000_000_000
112
+ key(:trigger_price).failure("must be less than 1,000,000,000")
113
+ end
114
+ end
115
+ end
116
+
117
+ rule(:bo_profit_value) do
118
+ if values[:bo_profit_value].is_a?(Float)
119
+ if values[:bo_profit_value].nan? || values[:bo_profit_value].infinite?
120
+ key(:bo_profit_value).failure("must be a finite number")
121
+ elsif values[:bo_profit_value] > 1_000_000_000
122
+ key(:bo_profit_value).failure("must be less than 1,000,000,000")
123
+ end
124
+ end
125
+ end
126
+
127
+ rule(:bo_stop_loss_value) do
128
+ if values[:bo_stop_loss_value].is_a?(Float)
129
+ if values[:bo_stop_loss_value].nan? || values[:bo_stop_loss_value].infinite?
130
+ key(:bo_stop_loss_value).failure("must be a finite number")
131
+ elsif values[:bo_stop_loss_value] > 1_000_000_000
132
+ key(:bo_stop_loss_value).failure("must be less than 1,000,000,000")
133
+ end
134
+ end
135
+ end
136
+
137
+ rule(:drv_strike_price) do
138
+ if values[:drv_strike_price].is_a?(Float)
139
+ if values[:drv_strike_price].nan? || values[:drv_strike_price].infinite?
140
+ key(:drv_strike_price).failure("must be a finite number")
141
+ elsif values[:drv_strike_price] > 1_000_000_000
142
+ key(:drv_strike_price).failure("must be less than 1,000,000,000")
143
+ end
144
+ end
145
+ end
146
+
96
147
  # Custom validation for trigger price when the order type is STOP_LOSS or STOP_LOSS_MARKET.
97
148
  rule(:trigger_price, :order_type) do
98
149
  if values[:order_type] =~ /^STOP_LOSS/ && !values[:trigger_price]
@@ -143,10 +143,13 @@ module DhanHQ
143
143
  # @return [Array<BaseModel>]
144
144
  def parse_collection_response(response)
145
145
  # Some endpoints return arrays, others might return a `[:data]` structure
146
- return [] unless response.is_a?(Array) || (response.is_a?(Hash) && response[:data].is_a?(Array))
146
+ unless response.is_a?(Array) || (response.is_a?(Hash) && response[:data].is_a?(Array))
147
+ DhanHQ.logger&.warn("[DhanHQ::BaseModel] Unexpected response format for collection: #{response.class}. Expected Array or Hash with :data key.")
148
+ return []
149
+ end
147
150
 
148
151
  collection = response.is_a?(Array) ? response : response[:data]
149
- collection.map { |record| new(record) }
152
+ collection.map { |record| new(record, skip_validation: true) }
150
153
  end
151
154
  end
152
155
 
@@ -155,11 +158,15 @@ module DhanHQ
155
158
  # Update an existing resource
156
159
  #
157
160
  # @param attributes [Hash] Attributes to update
158
- # @return [DhanHQ::BaseModel, DhanHQ::ErrorObject]
161
+ # @return [DhanHQ::BaseModel, DhanHQ::ErrorObject] Updated model instance or ErrorObject on failure
159
162
  def update(attributes = {})
160
163
  response = self.class.resource.put("/#{id}", params: attributes)
161
164
 
162
- success_response?(response) ? self.class.build_from_response(response) : DhanHQ::ErrorObject.new(response)
165
+ if success_response?(response)
166
+ self.class.build_from_response(response)
167
+ else
168
+ DhanHQ::ErrorObject.new(response)
169
+ end
163
170
  end
164
171
 
165
172
  # Persists the current resource by delegating to {#create} or {#update}.
@@ -196,10 +203,7 @@ module DhanHQ
196
203
  #
197
204
  # @return [Boolean] True when the server confirms deletion.
198
205
  def delete
199
- response = self.class.resource.delete("/#{id}")
200
- success_response?(response)
201
- rescue StandardError
202
- false
206
+ destroy
203
207
  end
204
208
 
205
209
  # Alias for {#delete} maintained for ActiveModel familiarity.
@@ -208,7 +212,8 @@ module DhanHQ
208
212
  def destroy
209
213
  response = self.class.resource.delete("/#{id}")
210
214
  success_response?(response)
211
- rescue StandardError
215
+ rescue StandardError => e
216
+ DhanHQ.logger&.error("[DhanHQ::BaseModel] Error deleting resource #{id}: #{e.class} #{e.message}")
212
217
  false
213
218
  end
214
219
 
@@ -222,16 +227,26 @@ module DhanHQ
222
227
 
223
228
  # Format request parameters before sending to API
224
229
  #
230
+ # Auto-injects dhan_client_id from configuration if not present in attributes
231
+ # and the model requires it (has dhan_client_id as an attribute).
232
+ #
225
233
  # @return [Hash] The camelCased attributes
226
234
  def to_request_params
227
- optionchain_api? ? titleize_keys(@attributes) : camelize_keys(@attributes)
235
+ attrs = @attributes.dup
236
+ # Auto-inject dhan_client_id from configuration if not provided and model supports it
237
+ if self.class.defined_attributes&.include?("dhan_client_id") && !attrs[:dhan_client_id] && DhanHQ.configuration.client_id
238
+ attrs[:dhan_client_id] = DhanHQ.configuration.client_id
239
+ end
240
+ optionchain_api? ? titleize_keys(attrs) : camelize_keys(attrs)
228
241
  end
229
242
 
230
243
  # Identifier inferred from the loaded attributes.
231
244
  #
232
245
  # @return [String, Integer, nil]
233
246
  def id
234
- @attributes[:id] || @attributes[:order_id] || @attributes[:security_id]
247
+ id_value = @attributes[:id] || @attributes[:order_id] || @attributes[:security_id]
248
+ # Convert to string for consistency, but preserve nil
249
+ id_value&.to_s
235
250
  end
236
251
 
237
252
  # Dynamically assign attributes as methods
data/lib/DhanHQ/errors.rb CHANGED
@@ -5,6 +5,8 @@ module DhanHQ
5
5
  class Error < StandardError; end
6
6
 
7
7
  # Authentication and access errors
8
+ # Raised when access token cannot be resolved (missing config or provider returned nil).
9
+ class AuthenticationError < Error; end
8
10
  # DH-901
9
11
  class InvalidAuthenticationError < Error; end
10
12
  # DH-902
@@ -13,6 +15,8 @@ module DhanHQ
13
15
  class UserAccountError < Error; end
14
16
  # DH-808
15
17
  class AuthenticationFailedError < Error; end
18
+ # DH-807: token expired (detected from API response; use with retry-on-401)
19
+ class TokenExpiredError < Error; end
16
20
  # DH-807, DH-809
17
21
  class InvalidTokenError < Error; end
18
22
  # DH-810
@@ -25,22 +25,37 @@ module DhanHQ
25
25
  #
26
26
  # @param path [String] The API endpoint path.
27
27
  # @return [Hash] The request headers.
28
+ # @raise [DhanHQ::InvalidAuthenticationError] If required headers are missing
28
29
  def build_headers(path)
29
30
  # Public CSV endpoint for segment-wise instruments requires no auth
30
31
  return { "Accept" => "text/csv" } if path.start_with?("/v2/instrument/")
31
32
 
33
+ token = resolved_access_token
34
+ raise DhanHQ::AuthenticationError, "Missing access token" if token.nil? || token.empty?
35
+
32
36
  headers = {
33
37
  "Content-Type" => "application/json",
34
38
  "Accept" => "application/json",
35
- "access-token" => DhanHQ.configuration.access_token
39
+ "access-token" => token
36
40
  }
37
41
 
38
42
  # Add client-id for DATA APIs
39
- headers["client-id"] = DhanHQ.configuration.client_id if data_api?(path)
43
+ if data_api?(path)
44
+ client_id = DhanHQ.configuration&.client_id
45
+ unless client_id
46
+ raise DhanHQ::InvalidAuthenticationError,
47
+ "client_id is required for DATA APIs but not set. Please configure DhanHQ with CLIENT_ID."
48
+ end
49
+ headers["client-id"] = client_id
50
+ end
40
51
 
41
52
  headers
42
53
  end
43
54
 
55
+ def resolved_access_token
56
+ DhanHQ.configuration&.resolved_access_token
57
+ end
58
+
44
59
  # Determines if the API path requires a `client-id` header.
45
60
  #
46
61
  # @param path [String] The API endpoint path.
@@ -29,7 +29,12 @@ module DhanHQ
29
29
  # @raise [DhanHQ::Error] If an HTTP error occurs.
30
30
  def handle_response(response)
31
31
  case response.status
32
- when 200..299 then parse_json(response.body)
32
+ when 200..201 then parse_json(response.body)
33
+ when 202
34
+ # 202 Accepted is used for async operations (e.g., position conversion)
35
+ # Return status hash to indicate success for async operations
36
+ { status: "accepted" }.with_indifferent_access
37
+ when 203..299 then parse_json(response.body)
33
38
  else handle_error(response)
34
39
  end
35
40
  end
@@ -49,16 +54,21 @@ module DhanHQ
49
54
 
50
55
  error_class = DhanHQ::Constants::DHAN_ERROR_MAPPING[error_code]
51
56
 
52
- error_class ||=
53
- case response.status
54
- when 400 then DhanHQ::InputExceptionError
55
- when 401 then DhanHQ::InvalidAuthenticationError
56
- when 403 then DhanHQ::InvalidAccessError
57
- when 404 then DhanHQ::NotFoundError
58
- when 429 then DhanHQ::RateLimitError
59
- when 500..599 then DhanHQ::InternalServerError
60
- else DhanHQ::OtherError
61
- end
57
+ unless error_class
58
+ # Log unmapped error codes for investigation
59
+ DhanHQ.logger&.warn("[DhanHQ] Unmapped error code: #{error_code} (status: #{response.status})")
60
+
61
+ error_class =
62
+ case response.status
63
+ when 400 then DhanHQ::InputExceptionError
64
+ when 401 then DhanHQ::InvalidAuthenticationError
65
+ when 403 then DhanHQ::InvalidAccessError
66
+ when 404 then DhanHQ::NotFoundError
67
+ when 429 then DhanHQ::RateLimitError
68
+ when 500..599 then DhanHQ::InternalServerError
69
+ else DhanHQ::OtherError
70
+ end
71
+ end
62
72
 
63
73
  error_text =
64
74
  if error_code == "DH-1111"
@@ -74,13 +84,24 @@ module DhanHQ
74
84
  #
75
85
  # @param body [String, Hash] The response body.
76
86
  # @return [HashWithIndifferentAccess, Array<HashWithIndifferentAccess>] The parsed JSON.
87
+ # @raise [DhanHQ::DataError] If JSON parsing fails (only for truly invalid JSON, not empty responses)
77
88
  def parse_json(body)
78
89
  parsed_body =
79
90
  if body.is_a?(String)
91
+ # Handle empty strings gracefully (backward compatible)
92
+ return {}.with_indifferent_access if body.strip.empty?
93
+
80
94
  begin
81
95
  JSON.parse(body, symbolize_names: true)
82
- rescue JSON::ParserError
83
- {} # Return an empty hash if the string is not valid JSON
96
+ rescue JSON::ParserError => e
97
+ # Log error but maintain backward compatibility for edge cases
98
+ # Only raise for clearly malformed JSON, not for empty/whitespace responses
99
+ DhanHQ.logger&.error("[DhanHQ] JSON parse error: #{e.message}")
100
+ DhanHQ.logger&.debug("[DhanHQ] Failed to parse body (first 200 chars): #{body[0..200]}")
101
+
102
+ # Raise DataError for invalid JSON (this is an improvement, not a breaking change)
103
+ # The API should never return invalid JSON, so this helps catch API issues
104
+ raise DhanHQ::DataError, "Failed to parse JSON response: #{e.message}"
84
105
  end
85
106
  else
86
107
  body