Skip to content

Commit 902dca5

Browse files
authored
Enhancements to Freshdesk Stream Handling (#15)
* Better error handling and tracing - Func overriding * Handling 300 pages pagination is has_more func - Class FreshdeskPaginator * Making tickets_abridged sorted asc by updated_at * Error response fix * Schema change tickets.json, association_type is number or null * 300 page API -pagination handling * Fixed JSONDecodeError and enumeration of error --------- Co-authored-by: prashantvikram <[email protected]>
1 parent 2d54409 commit 902dca5

File tree

3 files changed

+101
-18
lines changed

3 files changed

+101
-18
lines changed

tap_freshdesk/client.py

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from typing import Any, Callable, Iterable, TYPE_CHECKING, Generator
77

88
import requests
9+
from http import HTTPStatus
10+
from urllib.parse import urlparse
911
from singer_sdk.authenticators import BasicAuthenticator
1012
from singer_sdk.helpers.jsonpath import extract_jsonpath
1113
from singer_sdk.streams import RESTStream
@@ -20,6 +22,7 @@
2022

2123
class FreshdeskStream(RESTStream):
2224
"""freshdesk stream class."""
25+
2326
name: str
2427
records_jsonpath = "$.[*]" # Or override `parse_response`.
2528
primary_keys = ["id"]
@@ -33,11 +36,11 @@ def path(self) -> str:
3336
"""
3437
'groups' -> '/groups'
3538
"""
36-
return f'/{self.name}'
39+
return f"/{self.name}"
3740

3841
@property
3942
def schema_filepath(self) -> Path | None:
40-
return SCHEMAS_DIR / f'{self.name}.json'
43+
return SCHEMAS_DIR / f"{self.name}.json"
4144

4245
# OR use a dynamic url_base:
4346
@property
@@ -46,7 +49,6 @@ def url_base(self) -> str:
4649
domain = self.config["domain"]
4750
return f"https://{domain}.freshdesk.com/api/v2"
4851

49-
5052
@property
5153
def authenticator(self) -> BasicAuthenticator:
5254
"""Return a new authenticator object.
@@ -117,11 +119,11 @@ def get_url_params(
117119
A dictionary of URL query parameters.
118120
"""
119121
params: dict = {}
120-
embeds = self.config.get('embeds')
122+
embeds = self.config.get("embeds")
121123
if embeds:
122124
embed_fields = embeds.get(self.name, [])
123-
if embed_fields: # i.e. 'stats,company,sla_policy'
124-
params['include'] = ','.join(embed_fields)
125+
if embed_fields: # i.e. 'stats,company,sla_policy'
126+
params["include"] = ",".join(embed_fields)
125127
return params
126128

127129
def prepare_request_payload(
@@ -167,33 +169,76 @@ def post_process(self, row: dict, context: dict | None = None) -> dict | None:
167169
"""
168170
# TODO: Delete this method if not needed.
169171
return row
170-
172+
171173
def get_new_paginator(self) -> SinglePagePaginator:
172174
return SinglePagePaginator()
173-
175+
174176
def backoff_wait_generator(self) -> Generator[float, None, None]:
175177
return self.backoff_runtime(value=self._wait_for)
176-
178+
177179
@staticmethod
178180
def _wait_for(exception) -> int:
179181
"""
180182
When 429 thrown, header contains the time to wait before
181183
the next call is allowed, rather than use exponential backoff"""
182-
return int(exception.response.headers['Retry-After'])
183-
184+
return int(exception.response.headers["Retry-After"])
185+
184186
def backoff_jitter(self, value: float) -> float:
185187
return value
186188

189+
# Handling error, overriding this method over RESTStream's Class
190+
def response_error_message(self, response: requests.Response) -> str:
191+
"""Build error message for invalid http statuses.
192+
193+
WARNING - Override this method when the URL path may contain secrets or PII
194+
195+
Args:
196+
response: A :class:`requests.Response` object.
197+
198+
Returns:
199+
str: The error message
200+
"""
201+
full_path = urlparse(response.url).path or self.path
202+
error_type = (
203+
"Client"
204+
if HTTPStatus.BAD_REQUEST
205+
<= response.status_code
206+
< HTTPStatus.INTERNAL_SERVER_ERROR
207+
else "Server"
208+
)
209+
210+
error_details = []
211+
if response.status_code >= 400:
212+
print(f"Error Response: {response.status_code} {response.reason}")
213+
try:
214+
error_data = response.json()
215+
errors = error_data.get("errors")
216+
for index, error in enumerate(errors):
217+
message = error.get("message", "Unknown")
218+
field = error.get("field", "Unknown")
219+
error_details.append(
220+
f"Error {index + 1}: Message - {message}, Field - {field}"
221+
)
222+
except requests.exceptions.JSONDecodeError:
223+
return "Error: Unable to parse JSON error response"
224+
225+
return (
226+
f"{response.status_code} {error_type} Error: "
227+
f"{response.reason} for path: {full_path}. "
228+
f"Error via function response_error_message : {'. '.join(error_details)}."
229+
)
230+
231+
187232
class FreshdeskPaginator(BasePageNumberPaginator):
188233

189234
def has_more(self, response: Response) -> bool:
190235
"""
191236
There is no 'has more' indicator for this stream.
192-
If there are no results on this page, then this is 'last' page,
237+
If there are no results on this page, then this is 'last' page,
193238
(even though technically the page before was the last, there was no way to tell).
194239
"""
195-
return len(response.json())
196-
240+
return len(response.json()) != 0 and self.current_value < 300
241+
197242

198243
class PagedFreshdeskStream(FreshdeskStream):
199244

@@ -213,11 +258,11 @@ def get_url_params(
213258
"""
214259
context = context or {}
215260
params = super().get_url_params(context, next_page_token)
216-
params['per_page'] = 100
261+
params["per_page"] = 100
217262
if next_page_token:
218263
params["page"] = next_page_token
219-
if 'updated_since' not in context:
220-
params['updated_since'] = self.get_starting_timestamp(context)
264+
if "updated_since" not in context:
265+
params["updated_since"] = self.get_starting_timestamp(context)
221266
return params
222267

223268
def get_new_paginator(self) -> BasePageNumberPaginator:

tap_freshdesk/schemas/tickets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"association_type": {
5656
"type": [
5757
"null",
58-
"string"
58+
"number"
5959
]
6060
},
6161
"associated_tickets_count": {

tap_freshdesk/streams.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,44 @@ def path(self) -> str:
5454
def schema_filepath(self) -> Path | None:
5555
return SCHEMAS_DIR / 'tickets.json'
5656

57+
@property
58+
def is_sorted(self) -> bool:
59+
"""Expect stream to be sorted.
60+
61+
When `True`, incremental streams will attempt to resume if unexpectedly
62+
interrupted.
63+
64+
Returns:
65+
`True` if stream is sorted. Defaults to `False`.
66+
"""
67+
return True
68+
69+
def get_url_params(
70+
self,
71+
context: dict | None,
72+
next_page_token: Any | None,
73+
) -> dict[str, Any]:
74+
"""Return a dictionary of values to be used in URL parameterization.
75+
76+
Args:
77+
context: The stream context.
78+
next_page_token: The next page index or value.
79+
80+
Returns:
81+
A dictionary of URL query parameters.
82+
"""
83+
context = context or {}
84+
params = super().get_url_params(context, next_page_token)
85+
params['per_page'] = 100
86+
# Adding these parameters for sorting
87+
params['order_type'] = "asc"
88+
params['order_by'] = "updated_at"
89+
if next_page_token:
90+
params["page"] = next_page_token
91+
if 'updated_since' not in context:
92+
params['updated_since'] = self.get_starting_timestamp(context)
93+
return params
94+
5795
def get_records(self, context: dict | None) -> Iterable[dict[str, Any]]:
5896
context = context or {}
5997
records = self.request_records(context=context)

0 commit comments

Comments
 (0)